Add account admission tickets and nickname feedback for launcher 0.1.2
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
//! Ephemeral proof of possession for one Aeronautics connection.
|
||||
//!
|
||||
//! Neither this private key nor its ticket crosses IPC, enters launch arguments,
|
||||
//! or is persisted. Only the new Java child's environment receives them. A new
|
||||
//! launch obtains a new key and ticket; account session credentials stay native.
|
||||
|
||||
use crate::session::PlayerIdentity;
|
||||
use base64::{
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
Engine,
|
||||
};
|
||||
use ed25519_dalek::{
|
||||
pkcs8::{EncodePrivateKey, KeypairBytes},
|
||||
SigningKey,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
pub(crate) const TICKET_ENV: &str = "SHACRAFT_ADMISSION_TICKET";
|
||||
pub(crate) const PRIVATE_KEY_ENV: &str = "SHACRAFT_ADMISSION_PRIVATE_KEY";
|
||||
const SERVER_ID: &str = "aoc";
|
||||
const MAX_LIFETIME_SECONDS: u64 = 600;
|
||||
|
||||
// Deliberately no Debug, Clone or Serialize for secret-bearing values.
|
||||
pub(crate) struct AdmissionKey {
|
||||
public_key: String,
|
||||
private_key: Zeroizing<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct TicketRequest<'a> {
|
||||
server_id: &'static str,
|
||||
public_key: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub(crate) struct TicketResponse {
|
||||
ticket_id: String,
|
||||
mc_username: String,
|
||||
server_id: String,
|
||||
expires_in_seconds: u64,
|
||||
}
|
||||
|
||||
pub(crate) struct Admission {
|
||||
ticket_id: Zeroizing<String>,
|
||||
private_key: Zeroizing<String>,
|
||||
identity: PlayerIdentity,
|
||||
}
|
||||
|
||||
impl AdmissionKey {
|
||||
pub(crate) fn generate() -> Result<Self, &'static str> {
|
||||
let mut seed = Zeroizing::new([0_u8; 32]);
|
||||
getrandom::fill(seed.as_mut())
|
||||
.map_err(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?;
|
||||
let signing_key = SigningKey::from_bytes(&seed);
|
||||
let public_key = STANDARD.encode(signing_key.verifying_key().to_bytes());
|
||||
// RFC 8410 PKCS#8 v1 (PrivateKeyInfo) without the optional public key.
|
||||
// This is accepted by Java 21's Ed25519 KeyFactory/PKCS8EncodedKeySpec.
|
||||
let key_bytes = KeypairBytes {
|
||||
secret_key: signing_key.to_bytes(),
|
||||
public_key: None,
|
||||
};
|
||||
let encoded = key_bytes
|
||||
.to_pkcs8_der()
|
||||
.map_err(|_| "Не удалось подготовить защищённый ключ входа.")?;
|
||||
Ok(Self {
|
||||
public_key,
|
||||
private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn request(&self) -> TicketRequest<'_> {
|
||||
TicketRequest {
|
||||
server_id: SERVER_ID,
|
||||
public_key: &self.public_key,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bind(self, response: TicketResponse) -> Result<Admission, &'static str> {
|
||||
let ticket_id = Zeroizing::new(response.ticket_id);
|
||||
let valid_ticket = ticket_id.len() == 43
|
||||
&& URL_SAFE_NO_PAD
|
||||
.decode(ticket_id.as_bytes())
|
||||
.is_ok_and(|bytes| {
|
||||
bytes.len() == 32 && URL_SAFE_NO_PAD.encode(bytes) == *ticket_id
|
||||
});
|
||||
let valid_nickname = (3..=16).contains(&response.mc_username.len())
|
||||
&& response
|
||||
.mc_username
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
|
||||
if !valid_ticket
|
||||
|| !valid_nickname
|
||||
|| response.server_id != SERVER_ID
|
||||
|| !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds)
|
||||
{
|
||||
return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже.");
|
||||
}
|
||||
Ok(Admission {
|
||||
ticket_id,
|
||||
private_key: self.private_key,
|
||||
identity: PlayerIdentity::Offline {
|
||||
name: response.mc_username,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Admission {
|
||||
pub(crate) fn identity(&self) -> &PlayerIdentity {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
pub(crate) fn configure_child(&self, command: &mut Command) {
|
||||
command.env(TICKET_ENV, self.ticket_id.as_str());
|
||||
command.env(PRIVATE_KEY_ENV, self.private_key.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{pkcs8::DecodePrivateKey, Signer, Verifier};
|
||||
|
||||
fn response() -> TicketResponse {
|
||||
TicketResponse {
|
||||
ticket_id: URL_SAFE_NO_PAD.encode([37_u8; 32]),
|
||||
mc_username: "Canonical_Name".into(),
|
||||
server_id: SERVER_ID.into(),
|
||||
expires_in_seconds: 600,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_distinct_keys_and_only_sends_the_public_key() {
|
||||
let key = AdmissionKey::generate().unwrap();
|
||||
let other = AdmissionKey::generate().unwrap();
|
||||
assert_ne!(key.public_key, other.public_key);
|
||||
let payload = serde_json::to_value(key.request()).unwrap();
|
||||
assert_eq!(payload.as_object().unwrap().len(), 2);
|
||||
assert_eq!(payload["server_id"], SERVER_ID);
|
||||
assert_eq!(payload["public_key"], key.public_key);
|
||||
assert!(!payload.to_string().contains(key.private_key.as_str()));
|
||||
let der = Zeroizing::new(STANDARD.decode(key.private_key.as_bytes()).unwrap());
|
||||
let restored = SigningKey::from_pkcs8_der(&der).unwrap();
|
||||
assert_eq!(
|
||||
STANDARD.encode(restored.verifying_key().to_bytes()),
|
||||
key.public_key
|
||||
);
|
||||
let message = b"shacraft-admission-v1:challenge-fixture";
|
||||
restored
|
||||
.verifying_key()
|
||||
.verify(message, &restored.sign(message))
|
||||
.unwrap();
|
||||
// Java's standard Ed25519 encoding is the 48-byte private-key-only form.
|
||||
assert_eq!(der.len(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_untrusted_identity_ticket_server_and_expiry() {
|
||||
let mutations: Vec<Box<dyn Fn(&mut TicketResponse)>> = vec![
|
||||
Box::new(|r| r.ticket_id = "../unsafe".into()),
|
||||
Box::new(|r| r.ticket_id = "A".repeat(42) + "!"),
|
||||
Box::new(|r| r.ticket_id = "A".repeat(42) + "B"),
|
||||
Box::new(|r| r.mc_username = "../../outside".into()),
|
||||
Box::new(|r| r.mc_username = "ab".into()),
|
||||
Box::new(|r| r.mc_username = "a".repeat(17)),
|
||||
Box::new(|r| r.server_id = "other".into()),
|
||||
Box::new(|r| r.expires_in_seconds = 0),
|
||||
Box::new(|r| r.expires_in_seconds = 601),
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut payload = response();
|
||||
mutate(&mut payload);
|
||||
assert!(AdmissionKey::generate().unwrap().bind(payload).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_only_enter_the_child_environment_and_identity_comes_from_ticket() {
|
||||
let original_ticket = std::env::var_os(TICKET_ENV);
|
||||
let original_key = std::env::var_os(PRIVATE_KEY_ENV);
|
||||
let admission = AdmissionKey::generate().unwrap().bind(response()).unwrap();
|
||||
let mut command = Command::new("java");
|
||||
command
|
||||
.arg("-Xmx6144M")
|
||||
.arg("net.minecraft.client.main.Main");
|
||||
admission.configure_child(&mut command);
|
||||
assert_eq!(admission.identity().name(), "Canonical_Name");
|
||||
let env: std::collections::HashMap<_, _> = command.get_envs().collect();
|
||||
assert_eq!(
|
||||
env.get(std::ffi::OsStr::new(TICKET_ENV)).unwrap().unwrap(),
|
||||
admission.ticket_id.as_str()
|
||||
);
|
||||
assert_eq!(
|
||||
env.get(std::ffi::OsStr::new(PRIVATE_KEY_ENV))
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
admission.private_key.as_str()
|
||||
);
|
||||
assert_eq!(env.len(), 2);
|
||||
for argument in command.get_args() {
|
||||
assert!(!argument
|
||||
.to_string_lossy()
|
||||
.contains(admission.ticket_id.as_str()));
|
||||
assert!(!argument
|
||||
.to_string_lossy()
|
||||
.contains(admission.private_key.as_str()));
|
||||
}
|
||||
assert_eq!(std::env::var_os(TICKET_ENV), original_ticket);
|
||||
assert_eq!(std::env::var_os(PRIVATE_KEY_ENV), original_key);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{data_dir, shacraft::resolve_identity};
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
|
||||
settings,
|
||||
settings, shacraft_account,
|
||||
};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::Serialize;
|
||||
@@ -172,7 +172,6 @@ pub(crate) async fn launch_game(
|
||||
let manifest = remote::fetch_manifest(&profile_id).map_err(|error| error.to_string())?;
|
||||
let profile_dir = data_dir.join("profiles").join(&manifest.id);
|
||||
let settings = settings::load(&data_dir).map_err(|error| error.to_string())?;
|
||||
let identity = resolve_identity(&data_dir, &account_operation)?;
|
||||
|
||||
// Everything here should already be installed by `ensure_game_installed`,
|
||||
// so these are expected to hit their fast paths; no progress to show.
|
||||
@@ -201,12 +200,17 @@ pub(crate) async fn launch_game(
|
||||
std::fs::create_dir_all(&log_dir).map_err(|error| error.to_string())?;
|
||||
let log_path = log_dir.join(format!("{profile_id}-{timestamp}.log"));
|
||||
|
||||
// Generate fresh proof only after installation. Keep the account gate
|
||||
// through spawn so local logout/account switching cannot race issuance.
|
||||
let _account_permit = account_operation.acquire("ShaCraft account operation")?;
|
||||
let admission =
|
||||
shacraft_account::issue_admission(&data_dir).map_err(|error| error.to_string())?;
|
||||
let request = launch::LaunchRequest {
|
||||
java_executable: Path::new(&java_install.executable),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
identity: &identity,
|
||||
admission: &admission,
|
||||
memory_mb: settings.memory_mb,
|
||||
log_path: &log_path,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! ShaCraft sessions and verified account links are the launch identity source.
|
||||
use super::data_dir;
|
||||
use crate::{
|
||||
operations::{LauncherOperations, Operation},
|
||||
session, shacraft_account,
|
||||
};
|
||||
use crate::{operations::LauncherOperations, shacraft_account};
|
||||
use std::path::Path;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
@@ -75,6 +72,18 @@ pub(crate) async fn shacraft_start_link(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_claim_nickname(
|
||||
app: AppHandle,
|
||||
state: State<'_, LauncherOperations>,
|
||||
nickname: String,
|
||||
) -> Result<shacraft_account::Account, String> {
|
||||
account_task(&app, &state, move |directory| {
|
||||
shacraft_account::claim_nickname(directory, &nickname)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn shacraft_link_status(
|
||||
app: AppHandle,
|
||||
@@ -86,35 +95,3 @@ pub(crate) async fn shacraft_link_status(
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Always revalidate the server session and its aoc link. Legacy local settings
|
||||
/// and Microsoft tokens do not select the identity in the ShaCraft-only flow.
|
||||
pub(super) fn resolve_identity(
|
||||
directory: &Path,
|
||||
operation: &Operation,
|
||||
) -> Result<session::PlayerIdentity, String> {
|
||||
let _permit = operation.acquire("ShaCraft account operation")?;
|
||||
let name =
|
||||
shacraft_account::aeronautics_nickname(directory).map_err(|error| error.to_string())?;
|
||||
Ok(session::PlayerIdentity::Offline { name })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_shacraft_session_cannot_fall_back_to_legacy_nickname() {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"shacraft-identity-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
||||
assert!(resolve_identity(&directory, &Operation::default()).is_err());
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+83
-15
@@ -1,11 +1,10 @@
|
||||
//! Builds and spawns the real `java` invocation for a merged launch
|
||||
//! profile. The `${auth_*}` placeholders are filled from a `PlayerIdentity`,
|
||||
//! which is either a real Microsoft-authenticated session (`msa::LoginResult`)
|
||||
//! or an explicit offline account (`PlayerIdentity::Offline`). Offline mode is
|
||||
//! never silently substituted for a Microsoft session.
|
||||
//! profile. The `${auth_*}` placeholders use only the identity bound to the
|
||||
//! server-issued admission ticket. Its one-use proof stays out of arguments
|
||||
//! and files; only the Java child's environment receives it.
|
||||
|
||||
use crate::admission::Admission;
|
||||
use crate::mojang::{self, MergedVersion};
|
||||
use crate::session::PlayerIdentity;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -44,7 +43,7 @@ pub struct LaunchRequest<'a> {
|
||||
/// the shared `game_dir`.
|
||||
pub profile_dir: &'a Path,
|
||||
pub merged: &'a MergedVersion,
|
||||
pub identity: &'a PlayerIdentity,
|
||||
pub admission: &'a Admission,
|
||||
pub memory_mb: u16,
|
||||
pub log_path: &'a Path,
|
||||
}
|
||||
@@ -183,6 +182,10 @@ fn write_jvm_argfile(path: &Path, arguments: &[String]) -> io::Result<()> {
|
||||
/// that (see `lib.rs`'s launch command, which watches it on a background
|
||||
/// thread and emits an event).
|
||||
pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
Ok(build_command(request)?.spawn()?)
|
||||
}
|
||||
|
||||
fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
|
||||
fs::create_dir_all(request.profile_dir)?;
|
||||
let natives_dir = mojang::natives_directory(request.game_dir, &request.merged.id);
|
||||
fs::create_dir_all(&natives_dir)?;
|
||||
@@ -193,7 +196,8 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
let classpath = build_classpath(request.game_dir, request.merged, &client_jar);
|
||||
|
||||
let mut vars: HashMap<&str, String> = HashMap::new();
|
||||
vars.insert("auth_player_name", request.identity.name().to_string());
|
||||
let identity = request.admission.identity();
|
||||
vars.insert("auth_player_name", identity.name().to_string());
|
||||
// NeoForge's inherited JVM profile uses `${version_name}.jar` in
|
||||
// `-DignoreList`. The actual client jar belongs to the vanilla parent
|
||||
// (`1.21.1.jar`), not to the child profile (`neoforge-...`), so this
|
||||
@@ -203,14 +207,11 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
vars.insert("game_directory", request.profile_dir.display().to_string());
|
||||
vars.insert("assets_root", assets_root.display().to_string());
|
||||
vars.insert("assets_index_name", request.merged.asset_index.id.clone());
|
||||
vars.insert("auth_uuid", request.identity.uuid());
|
||||
vars.insert(
|
||||
"auth_access_token",
|
||||
request.identity.access_token().to_string(),
|
||||
);
|
||||
vars.insert("auth_uuid", identity.uuid());
|
||||
vars.insert("auth_access_token", identity.access_token().to_string());
|
||||
vars.insert("clientid", launcher_client_id(request.game_dir)?);
|
||||
vars.insert("auth_xuid", request.identity.xuid().to_string());
|
||||
vars.insert("user_type", request.identity.user_type().to_string());
|
||||
vars.insert("auth_xuid", identity.xuid().to_string());
|
||||
vars.insert("user_type", identity.user_type().to_string());
|
||||
vars.insert("version_type", "ShaCraft Launcher".to_string());
|
||||
vars.insert("natives_directory", natives_dir.display().to_string());
|
||||
vars.insert("launcher_name", "ShaCraft Launcher".to_string());
|
||||
@@ -227,6 +228,7 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
let game_args = mojang::resolve_arguments(&request.merged.game_arguments, &no_features);
|
||||
|
||||
let mut command = Command::new(request.java_executable);
|
||||
request.admission.configure_child(&mut command);
|
||||
let memory_argument = format!("-Xmx{}M", request.memory_mb);
|
||||
if cfg!(windows) {
|
||||
let argfile = request.profile_dir.join(".shacraft-jvm.args");
|
||||
@@ -249,13 +251,79 @@ pub fn launch(request: &LaunchRequest) -> Result<Child, LaunchError> {
|
||||
command.stdout(Stdio::from(log_file.try_clone()?));
|
||||
command.stderr(Stdio::from(log_file));
|
||||
|
||||
Ok(command.spawn()?)
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_and_written_files_never_contain_admission_secrets() {
|
||||
use crate::admission::{AdmissionKey, PRIVATE_KEY_ENV, TICKET_ENV};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
|
||||
let directory =
|
||||
std::env::temp_dir().join(format!("shacraft-launch-proof-{}", random_uuid_v4()));
|
||||
let game_dir = directory.join("game");
|
||||
let profile_dir = directory.join("profile");
|
||||
let log_path = directory.join("game.log");
|
||||
let vanilla: mojang::VersionJson = serde_json::from_value(serde_json::json!({
|
||||
"id": "1.21.1",
|
||||
"mainClass": "net.minecraft.client.main.Main",
|
||||
"arguments": {
|
||||
"game": ["--username", "${auth_player_name}", "--uuid", "${auth_uuid}", "--accessToken", "${auth_access_token}"],
|
||||
"jvm": ["-cp", "${classpath}", "-Dlauncher=${launcher_name}"]
|
||||
},
|
||||
"assetIndex": {"id": "17", "sha1": "0".repeat(40), "size": 1, "url": "https://piston-meta.mojang.com/assets"},
|
||||
"downloads": {"client": {"sha1": "0".repeat(40), "size": 1, "url": "https://piston-data.mojang.com/client.jar"}}
|
||||
})).unwrap();
|
||||
let merged = mojang::merge_versions(&vanilla, None).unwrap();
|
||||
let response = serde_json::from_value(serde_json::json!({
|
||||
"ticket_id": URL_SAFE_NO_PAD.encode([73_u8; 32]), "mc_username": "Ticket_Name",
|
||||
"server_id": "aoc", "expires_in_seconds": 600
|
||||
}))
|
||||
.unwrap();
|
||||
let admission = AdmissionKey::generate().unwrap().bind(response).unwrap();
|
||||
let request = LaunchRequest {
|
||||
java_executable: Path::new("java"),
|
||||
game_dir: &game_dir,
|
||||
profile_dir: &profile_dir,
|
||||
merged: &merged,
|
||||
admission: &admission,
|
||||
memory_mb: 6144,
|
||||
log_path: &log_path,
|
||||
};
|
||||
let command = build_command(&request).unwrap();
|
||||
let env: HashMap<_, _> = command.get_envs().collect();
|
||||
let proof = [TICKET_ENV, PRIVATE_KEY_ENV]
|
||||
.map(|name| env[std::ffi::OsStr::new(name)].unwrap().to_str().unwrap());
|
||||
let arguments: Vec<_> = command
|
||||
.get_args()
|
||||
.map(|arg| arg.to_string_lossy())
|
||||
.collect();
|
||||
assert!(arguments
|
||||
.windows(2)
|
||||
.any(|args| args == ["--username", "Ticket_Name"]));
|
||||
assert!(arguments
|
||||
.windows(2)
|
||||
.any(|args| args == ["--accessToken", "0"]));
|
||||
for secret in proof {
|
||||
assert!(arguments.iter().all(|argument| !argument.contains(secret)));
|
||||
for path in [
|
||||
log_path.clone(),
|
||||
game_dir.join(".shacraft-client-id"),
|
||||
profile_dir.join(".shacraft-jvm.args"),
|
||||
] {
|
||||
if path.exists() {
|
||||
assert!(!fs::read_to_string(path).unwrap().contains(secret));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(command);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_rfc4122_version_4_uuids() {
|
||||
let id = random_uuid_v4();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod admission;
|
||||
mod download;
|
||||
mod java;
|
||||
mod launch;
|
||||
@@ -34,6 +35,7 @@ pub fn run() {
|
||||
commands::shacraft::get_shacraft_account,
|
||||
commands::shacraft::shacraft_logout,
|
||||
commands::shacraft::shacraft_start_link,
|
||||
commands::shacraft::shacraft_claim_nickname,
|
||||
commands::shacraft::shacraft_link_status,
|
||||
commands::account::start_microsoft_login,
|
||||
commands::account::get_account,
|
||||
|
||||
@@ -6,7 +6,12 @@
|
||||
use reqwest::blocking::{Client, Response};
|
||||
use reqwest::redirect::Policy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, fs, io, path::Path, time::Duration};
|
||||
use std::{
|
||||
fmt, fs,
|
||||
io::{self, Read},
|
||||
path::Path,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
const API_ORIGIN: &str = "https://shacraft.ru";
|
||||
const SESSION_FILE: &str = "shacraft-session";
|
||||
@@ -62,7 +67,6 @@ pub enum AccountError {
|
||||
Api(String),
|
||||
Io(io::Error),
|
||||
InvalidSession,
|
||||
NoLinkedNickname,
|
||||
}
|
||||
|
||||
impl fmt::Display for AccountError {
|
||||
@@ -72,9 +76,6 @@ impl fmt::Display for AccountError {
|
||||
Self::Api(message) => formatter.write_str(message),
|
||||
Self::Io(error) => write!(formatter, "Не удалось сохранить сессию: {error}"),
|
||||
Self::InvalidSession => formatter.write_str("Сессия ShaCraft истекла — войдите снова"),
|
||||
Self::NoLinkedNickname => {
|
||||
formatter.write_str("Сначала привяжите игровой ник к серверу Aeronautics")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,18 +222,95 @@ pub fn link_status(data_dir: &Path, challenge_id: i64) -> Result<LinkStatus, Acc
|
||||
response.json::<LinkStatus>().map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
pub fn aeronautics_nickname(data_dir: &Path) -> Result<String, AccountError> {
|
||||
get_account(data_dir)?
|
||||
.links
|
||||
.into_iter()
|
||||
.find(|link| link.server_id == "aoc")
|
||||
.map(|link| link.mc_username)
|
||||
.ok_or(AccountError::NoLinkedNickname)
|
||||
const ADMISSION_ENDPOINT: &str = "/api/launcher/v2/admission/tickets";
|
||||
|
||||
/// Error responses at this boundary never echo arbitrary response bodies: a
|
||||
/// misconfigured proxy/service must not copy credentials into UI diagnostics.
|
||||
fn admission_error(status: reqwest::StatusCode) -> AccountError {
|
||||
use reqwest::StatusCode;
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => AccountError::InvalidSession,
|
||||
StatusCode::FORBIDDEN => AccountError::Api(
|
||||
"Нет разрешения на вход в Aeronautics. Проверьте привязку ника и доступ к серверу в аккаунте ShaCraft.".into()),
|
||||
StatusCode::CONFLICT => AccountError::Api(
|
||||
"Этот ник уже занят или зарезервирован. Если это ваш игровой ник, обратитесь в поддержку ShaCraft.".into()),
|
||||
StatusCode::NOT_FOUND | StatusCode::SERVICE_UNAVAILABLE => AccountError::Api(
|
||||
"Вход через ShaCraft Launcher пока не настроен на сервере. Повторите попытку позже.".into()),
|
||||
StatusCode::TOO_MANY_REQUESTS => AccountError::Api(
|
||||
"Слишком много запросов входа. Подождите немного и повторите попытку.".into()),
|
||||
_ => AccountError::Api(format!("Не удалось получить разрешение ShaCraft: HTTP {status}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_admission_response(
|
||||
data_dir: &Path,
|
||||
response: Response,
|
||||
) -> Result<Response, AccountError> {
|
||||
if response.status().is_success() {
|
||||
return Ok(response);
|
||||
}
|
||||
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let _ = fs::remove_file(session_path(data_dir));
|
||||
}
|
||||
Err(admission_error(response.status()))
|
||||
}
|
||||
|
||||
/// The server reserves a free nickname atomically for this account. Existing
|
||||
/// player names remain reserved for administrator-assisted migration.
|
||||
pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result<Account, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let response = client()?
|
||||
.post(format!("{API_ORIGIN}/api/launcher/v2/admission/nickname"))
|
||||
.bearer_auth(token)
|
||||
.json(&serde_json::json!({"server_id": "aoc", "mc_username": nickname}))
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
checked_admission_response(data_dir, response)?
|
||||
.json()
|
||||
.map_err(AccountError::Network)
|
||||
}
|
||||
|
||||
/// Called only after installation, immediately before Java spawn. Nothing in
|
||||
/// this response is exposed to the webview or persisted with account settings.
|
||||
pub(crate) fn issue_admission(
|
||||
data_dir: &Path,
|
||||
) -> Result<crate::admission::Admission, AccountError> {
|
||||
let token = load_session(data_dir)?;
|
||||
let key = crate::admission::AdmissionKey::generate()
|
||||
.map_err(|message| AccountError::Api(message.into()))?;
|
||||
let response = client()?
|
||||
.post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}"))
|
||||
.bearer_auth(token)
|
||||
.json(&key.request())
|
||||
.send()
|
||||
.map_err(AccountError::Network)?;
|
||||
let response = checked_admission_response(data_dir, response)?;
|
||||
// The expected object is under 256 bytes; bound the remote allocation and
|
||||
// use a fixed parse error without response values or secret-bearing bodies.
|
||||
let mut body = zeroize::Zeroizing::new(Vec::new());
|
||||
response.take(4097).read_to_end(&mut body).map_err(|_| {
|
||||
AccountError::Api(
|
||||
"Не удалось прочитать разрешение на вход. Повторите попытку позже.".into(),
|
||||
)
|
||||
})?;
|
||||
let invalid = || {
|
||||
AccountError::Api(
|
||||
"Сервер вернул некорректное разрешение на вход. Повторите попытку позже.".into(),
|
||||
)
|
||||
};
|
||||
if body.len() > 4096 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let payload = serde_json::from_slice(&body).map_err(|_| invalid())?;
|
||||
key.bind(payload)
|
||||
.map_err(|message| AccountError::Api(message.into()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{load_session, save_session, session_path};
|
||||
use super::{
|
||||
admission_error, issue_admission, load_session, save_session, session_path, AccountError,
|
||||
};
|
||||
use std::{
|
||||
fs, process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
@@ -275,4 +353,34 @@ mod tests {
|
||||
);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_without_session_never_falls_back_to_legacy_nickname() {
|
||||
let directory = temporary_directory();
|
||||
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
|
||||
assert!(matches!(
|
||||
issue_admission(&directory),
|
||||
Err(AccountError::InvalidSession)
|
||||
));
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admission_unavailable_and_revoked_session_have_actionable_errors() {
|
||||
for status in [
|
||||
reqwest::StatusCode::NOT_FOUND,
|
||||
reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
] {
|
||||
assert!(admission_error(status)
|
||||
.to_string()
|
||||
.contains("пока не настроен"));
|
||||
}
|
||||
assert!(matches!(
|
||||
admission_error(reqwest::StatusCode::UNAUTHORIZED),
|
||||
AccountError::InvalidSession
|
||||
));
|
||||
assert!(admission_error(reqwest::StatusCode::CONFLICT)
|
||||
.to_string()
|
||||
.contains("зарезервирован"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user