245 lines
9.2 KiB
Rust
245 lines
9.2 KiB
Rust
//! 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";
|
|
#[cfg(test)]
|
|
const SERVER_ID: &str = "aoc";
|
|
|
|
pub(crate) fn server_for_profile(profile: &str) -> Result<&'static str, &'static str> {
|
|
match profile {
|
|
"aeronautics" => Ok("aoc"),
|
|
"minigames" => Ok("minigames"),
|
|
_ => Err("Unknown ShaCraft profile"),
|
|
}
|
|
}
|
|
const MAX_LIFETIME_SECONDS: u64 = 600;
|
|
|
|
// Deliberately no Debug, Clone or Serialize for secret-bearing values.
|
|
pub(crate) struct AdmissionKey {
|
|
server_id: &'static str,
|
|
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 {
|
|
server_id: &'static str,
|
|
ticket_id: Zeroizing<String>,
|
|
private_key: Zeroizing<String>,
|
|
identity: PlayerIdentity,
|
|
}
|
|
|
|
impl AdmissionKey {
|
|
pub(crate) fn generate(server_id: &'static str) -> Result<Self, &'static str> {
|
|
if !matches!(server_id, "aoc" | "minigames") { return Err("Unknown ShaCraft server"); }
|
|
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 {
|
|
server_id,
|
|
public_key,
|
|
private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())),
|
|
})
|
|
}
|
|
|
|
pub(crate) fn request(&self) -> TicketRequest<'_> {
|
|
TicketRequest {
|
|
server_id: self.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 != self.server_id
|
|
|| !(1..=MAX_LIFETIME_SECONDS).contains(&response.expires_in_seconds)
|
|
{
|
|
return Err("Сервер вернул некорректное разрешение на вход. Повторите попытку позже.");
|
|
}
|
|
Ok(Admission {
|
|
server_id: self.server_id,
|
|
ticket_id,
|
|
private_key: self.private_key,
|
|
identity: PlayerIdentity::Offline {
|
|
name: response.mc_username,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Admission {
|
|
pub(crate) fn server_id(&self) -> &str { self.server_id }
|
|
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 profile_tickets_are_server_bound_with_shared_canonical_identity() {
|
|
assert_eq!(server_for_profile("aeronautics").unwrap(), "aoc");
|
|
assert_eq!(server_for_profile("minigames").unwrap(), "minigames");
|
|
assert!(server_for_profile("../../other").is_err());
|
|
assert!(AdmissionKey::generate("other").is_err());
|
|
assert!(AdmissionKey::generate("minigames").unwrap().bind(response()).is_err());
|
|
let key=AdmissionKey::generate("minigames").unwrap();
|
|
assert_eq!(serde_json::to_value(key.request()).unwrap()["server_id"], "minigames");
|
|
let mut payload=response(); payload.server_id="minigames".into();
|
|
let admitted=key.bind(payload).unwrap();
|
|
assert_eq!(admitted.server_id(), "minigames");
|
|
assert_eq!(admitted.identity().name(), "Canonical_Name");
|
|
}
|
|
|
|
#[test]
|
|
fn generates_distinct_keys_and_only_sends_the_public_key() {
|
|
let key = AdmissionKey::generate("aoc").unwrap();
|
|
let other = AdmissionKey::generate("aoc").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("aoc").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("aoc").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);
|
|
}
|
|
}
|