Add account admission tickets and nickname feedback for launcher 0.1.2
This commit is contained in:
@@ -41,11 +41,28 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
control a URL in any of those domains.
|
||||
- ShaCraft accounts: `src-tauri/src/shacraft_account.rs` talks only to the
|
||||
hardcoded `https://shacraft.ru` origin. Passwords are never persisted. The
|
||||
revocable session token is stored locally with mode 600 on Unix. At launch,
|
||||
the nickname is fetched from the verified `aoc` account link; the legacy
|
||||
nickname in `settings.json` is ignored as an identity source. Server-side
|
||||
whitelist enforcement and LoginSystem remain the final access-control
|
||||
boundary, including for old launcher versions.
|
||||
revocable session token is stored locally with mode 600 on Unix and never
|
||||
passed to Java. The new admission flow uses fixed POST endpoints
|
||||
`/api/launcher/v2/admission/nickname` and
|
||||
`/api/launcher/v2/admission/tickets`; redirects are rejected. Neither the
|
||||
manifest nor the webview can select their origin or URL.
|
||||
- Free nicknames are claimed directly for the authenticated account. Existing
|
||||
player names are reserved server-side and assigned by an administrator;
|
||||
deleting a website account must not make an existing player's name free.
|
||||
- After installation, `admission.rs` generates an ephemeral Ed25519 key with
|
||||
the OS CSPRNG. The backend binds its public key to a one-use ticket, current
|
||||
account session, canonical `aoc` nickname and server access. Only that
|
||||
returned nickname selects launch identity; `settings.json` is never a fallback.
|
||||
Ticket and PKCS#8 private key go only in the final Java child's environment
|
||||
(`SHACRAFT_ADMISSION_TICKET`, `SHACRAFT_ADMISSION_PRIVATE_KEY`). Never put
|
||||
them in global environment, argv/argfiles, settings, logs or IPC. A fresh
|
||||
launch obtains a fresh ticket; there is no shared launcher secret.
|
||||
- Once admission is enforced on Aeronautics, its server mod verifies the
|
||||
challenge/proof before world entry, and server-side whitelist enforcement
|
||||
remains a final access boundary. Replace LoginSystem only as part of the
|
||||
validated `aoc` rollout; other servers are unaffected. Old launchers without
|
||||
admission proof will be rejected after enforcement. This authenticates an
|
||||
account's permission, not the integrity of an unmodified launcher binary.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -60,7 +77,8 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
for account/game/host/preferences/profiles. Unsigned sync/inspect IPC was
|
||||
removed; only verified remote manifests may drive profile mutations.
|
||||
- `operations.rs` — process-local install/account permits owned by workers.
|
||||
Launch must use the authenticated ShaCraft nickname; no settings fallback.
|
||||
The account permit covers ticket issuance through Java spawn, preventing
|
||||
local logout/account switching from racing that handoff.
|
||||
- `storage.rs` — unique same-directory atomic writes, owner-only Unix files.
|
||||
- `trusted_http.rs` — HTTPS and exact-host redirect policy per game provider.
|
||||
- `download.rs` — shared verified-download helper (temp file, hash,
|
||||
@@ -76,8 +94,11 @@ payload are in `/root/shacraft` on the ShaCraft host; see
|
||||
placeholder pending ShaCraft's own Azure AD app registration and
|
||||
Minecraft-API approval.
|
||||
- `shacraft_account.rs` — local ShaCraft login/registration, session and
|
||||
verified nickname-link API.
|
||||
- `launch.rs` — builds and spawns the actual `java` process.
|
||||
nickname claim/admission API; legacy link commands remain for compatibility.
|
||||
- `admission.rs` — ephemeral key generation, strict ticket response validation,
|
||||
canonical identity and child-only admission environment.
|
||||
- `launch.rs` — builds and spawns the actual `java` process; admission secrets
|
||||
must remain outside its argument substitution and JVM argfile paths.
|
||||
- `src-tauri/src/settings.rs` — durable local preferences; maintain backward
|
||||
compatibility with already-written JSON.
|
||||
- `docs/manifest-v1.md` — signed manifest envelope and payload contract
|
||||
@@ -105,6 +126,14 @@ not prove Tauri commands work.
|
||||
|
||||
See `PLAN.md` for known gaps. Never label browser preview or unit tests as
|
||||
a successful cold game install / Microsoft OAuth / Windows/macOS beta test.
|
||||
The admission implementation has local unit/UI checks and unsigned Linux
|
||||
0.1.2 AppImage/deb packages built on Ubuntu 26.04. Older Ubuntu compatibility
|
||||
has not been tested. The backend/mod rollout is active on Aeronautics as of
|
||||
2026-09-10. Real isolated NeoForge admission tests and a production rejection
|
||||
without the mod passed; these do not certify a full production modpack join.
|
||||
The server has a required early duplicate-login guard so unauthenticated
|
||||
connections cannot evict an already-online UUID. The client mod pins the actual
|
||||
socket to `135.106.154.86:25567`. See architecture and server rollout records.
|
||||
|
||||
## Working conventions
|
||||
|
||||
|
||||
@@ -24,9 +24,50 @@
|
||||
- [ ] Динамический каталог и новости; реальный Aeronautics онлайн уже
|
||||
загружается через фиксированный display-only API. Не имитировать данные.
|
||||
|
||||
## Вход по одноразовому разрешению: внедрение 2026-09-10
|
||||
|
||||
- [x] Новый native claim закрепляет свободный ник за аккаунтом без входа в игру.
|
||||
Старые имена резервируются backend и переносятся администратором.
|
||||
- [x] После установки Rust создаёт Ed25519 ключ через OS CSPRNG и запрашивает
|
||||
ticket на фиксированном ShaCraft API. Ник для запуска берётся из ticket;
|
||||
локальные настройки не могут его заменить.
|
||||
- [x] Ticket и PKCS#8 private key передаются только окружением дочерней Java;
|
||||
session сайта остаётся в native. В argv, argfile, settings, логах и IPC
|
||||
секретов нет. Account permit держится до spawn.
|
||||
- [x] Ошибки и результаты привязки показаны в диалоге; двойной клик не создаёт
|
||||
параллельных запросов, старого polling нет.
|
||||
- [x] Локально пройдены 64 Rust-теста (5 live-тестов пропущены), 22 UI-теста,
|
||||
TypeScript/Vite и сборка Linux x86-64 `tauri:build -- --no-bundle`.
|
||||
Шесть браузерных сценариев используют только замоканный Tauri IPC.
|
||||
- [x] Собраны неподписанные AppImage и deb версии 0.1.2 на Ubuntu 26.04;
|
||||
проверены извлечение AppImage и metadata deb. Работа на старых Ubuntu и
|
||||
установка пакетов на чистой машине этим не подтверждены.
|
||||
- [x] Выложены backend, подписанный клиентский мод и серверный мод Aeronautics;
|
||||
сервер перезапущен и healthy. Whitelist сохранён; LoginSystem заменён только
|
||||
на `aoc`. Пакеты Linux 0.1.2 опубликованы на сайте, AppImage запущен локально.
|
||||
- [x] Настоящий изолированный NeoForge: новый ticket допускает в мир, отсутствие
|
||||
мода/билета и повтор билета отклоняются. Старый LoginSystem на клиенте не мешает.
|
||||
Подключение дубликата без авторизации не выбивает уже играющего владельца.
|
||||
- [x] На публичном сервере подключение без мода отклонено до входа в мир;
|
||||
HTTPS verifier из контейнера, подпись manifest и SHA-256 мода проверены.
|
||||
Backend Docker: 404 tests + 48 subtests, включая истечение, отзыв session,
|
||||
чужую identity, whitelist, резервирование имён и атомарное погашение.
|
||||
- [ ] Полный вход в production Aeronautics через установленный лаунчер
|
||||
подтвердить отдельно; изолированный мир не заменяет проверку полного модпака.
|
||||
- [ ] Проверить cold install и этот протокол на Windows/macOS, выпустить
|
||||
подписанные пакеты. Локальная Linux-сборка не подтверждает эти платформы.
|
||||
- [ ] Удобное переподключение: сейчас использованный или истёкший ticket
|
||||
требует нового запуска игры из лаунчера; автоматического обновления нет.
|
||||
|
||||
## Связанные серверные риски
|
||||
|
||||
Серверный план находится в `/root/shacraft/PLAN.md`. Важные следующие шаги:
|
||||
одноразовое подтверждение ника внутри игры (NoGravity не связывает игрока
|
||||
с веб-запросом), enforcement реферальных правил и очередь повторов whitelist.
|
||||
Не менять этот протокол незаметно в клиентском рефакторинге.
|
||||
Серверный план находится в `/root/shacraft/PLAN.md`. Для admission обязательны
|
||||
атомарная одноразовая проверка ticket, привязка к текущим session/аккаунту/нику,
|
||||
проверка до входа в мир и сохранение whitelist как серверного ограничения.
|
||||
Старые игровые имена нельзя отдавать первому зарегистрировавшемуся; их
|
||||
резервирование и назначение аккаунтам — отдельный этап миграции. Старый
|
||||
NoGravity challenge-поток должен быть закрыт при включённом admission, чтобы
|
||||
он не обходил резервирование имени. Остальные серверные задачи включают
|
||||
enforcement реферальных правил и очередь повторов whitelist. Не менять
|
||||
протокол незаметно в клиентском рефакторинге и не считать ticket доказательством
|
||||
неизменённости клиентского бинарника.
|
||||
|
||||
+106
-10
@@ -5,9 +5,18 @@
|
||||
The launcher persists local settings, synchronises Aeronautics mod/config
|
||||
files from the signed ShaCraft v2 manifest, installs the exact Minecraft +
|
||||
NeoForge version the manifest specifies, and launches the game. A player
|
||||
signs in with the same local ShaCraft account used on the website. The game
|
||||
identity is derived only from that account's verified Aeronautics nickname;
|
||||
the legacy editable nickname setting is not trusted at launch.
|
||||
signs in with the same local ShaCraft account used on the website. The current
|
||||
admission implementation claims a free nickname for that account and requests
|
||||
a one-use permission immediately before launching Java. The game identity
|
||||
comes only from the canonical Aeronautics nickname in that permission; the
|
||||
legacy editable nickname setting is not trusted at launch.
|
||||
|
||||
The backend and admission mod were deployed to Aeronautics on 2026-09-10;
|
||||
the server is healthy with whitelist enforcement retained. Real isolated
|
||||
NeoForge connections verified successful admission, absent/replayed proof
|
||||
rejection and protection of an online player from duplicate login. A public
|
||||
production connection without the mod was rejected before world entry.
|
||||
This does not certify a full cold installation or Windows/macOS operation.
|
||||
|
||||
The interface also shows a live Aeronautics player count from the fixed,
|
||||
read-only `https://shacraft.ru/api/online/aoc` endpoint. It is display-only:
|
||||
@@ -19,7 +28,7 @@ launcher itself. Do not represent these as completed in UI or release notes.
|
||||
|
||||
## Data flow
|
||||
|
||||
Two independent pipelines feed one launch:
|
||||
Managed files, game installation and account admission meet at Java spawn:
|
||||
|
||||
```text
|
||||
ShaCraft manifest (mods/config + which MC/loader/Java version to use)
|
||||
@@ -33,9 +42,14 @@ Game itself (never controlled by the manifest above)
|
||||
-> NeoForge's own installer, run headlessly (neoforge.rs)
|
||||
-> generic inheritsFrom merge of the two version JSONs (mojang.rs)
|
||||
-> SHA-1-verified merged libraries + platform natives (mojang.rs)
|
||||
-> verified ShaCraft account link (shacraft_account.rs)
|
||||
-> deterministic offline UUID for the linked nickname (session.rs)
|
||||
-> java process spawned with the merged classpath/args (launch.rs)
|
||||
|
||||
Admission (fixed ShaCraft account API; never controlled by a manifest)
|
||||
native website session -> direct free-nickname claim or admin migration
|
||||
-> after installation: OS CSPRNG -> ephemeral Ed25519 key (admission.rs)
|
||||
-> public key + bearer session -> one-use ticket for canonical aoc nickname
|
||||
-> deterministic offline UUID for that nickname (session.rs)
|
||||
-> Java with merged classpath/args + child-only ticket/private-key environment
|
||||
-> client mod signs server challenge; server validates before world entry
|
||||
```
|
||||
|
||||
Profiles (ShaCraft-managed mods/config, and the player's own worlds/
|
||||
@@ -45,7 +59,8 @@ install (versions/libraries/assets/runtime, reused across profiles that
|
||||
target the same Minecraft version) lives at `app_data_dir()/game`. Settings
|
||||
live at `app_data_dir()/settings.json`, and the revocable ShaCraft session at
|
||||
`app_data_dir()/shacraft-session` (mode 600 on Unix). Passwords are never
|
||||
written to disk. None of these should be assumed to
|
||||
written to disk. The admission private key and ticket are ephemeral native
|
||||
values and are not persisted. None of these directories should be assumed to
|
||||
be the system `.minecraft` directory.
|
||||
|
||||
## Aeronautics contract
|
||||
@@ -60,8 +75,68 @@ be the system `.minecraft` directory.
|
||||
- ShaCraft download files: HTTPS only, exact hosts `shacraft.ru` and
|
||||
`cdn.shacraft.ru`.
|
||||
- Account API origin: fixed `https://shacraft.ru`; redirects are rejected.
|
||||
- Launch identity: the most recently verified `aoc` nickname returned by the
|
||||
authenticated account API. Local nickname edits cannot select an identity.
|
||||
- Launch identity: the canonical `aoc` nickname returned with the admission
|
||||
ticket. Local nickname edits cannot select an identity.
|
||||
|
||||
## Admission contract (implementation: 2026-09-10)
|
||||
|
||||
Both endpoints use the fixed account API origin, HTTPS without redirects and
|
||||
the native website session as bearer authentication. The session is never
|
||||
passed to the client mod or Java process.
|
||||
|
||||
`POST /api/launcher/v2/admission/nickname` accepts
|
||||
`{server_id: "aoc", mc_username: "Chosen_Name"}` and returns the existing
|
||||
account shape `{username, links}`. The backend atomically assigns a free name
|
||||
to that account. Existing player names remain reserved and require explicit
|
||||
administrator migration. The launcher no longer asks the player to enter the
|
||||
game to complete this claim. Legacy challenge IPC remains available to older
|
||||
flows, but an admission-enabled backend must block those legacy endpoints
|
||||
from bypassing reserved-name ownership.
|
||||
|
||||
After installation completes, the native launcher generates a fresh Ed25519
|
||||
key using the OS CSPRNG and calls
|
||||
`POST /api/launcher/v2/admission/tickets` with
|
||||
`{server_id: "aoc", public_key: "<standard base64 raw 32-byte public key>"}`.
|
||||
The response is `{ticket_id, mc_username, server_id, expires_in_seconds}`.
|
||||
The native boundary requires a canonical 43-character base64url ticket ID,
|
||||
an ASCII Minecraft nickname of 3–16 letters/digits/underscores, server `aoc`
|
||||
and a positive lifetime of at most 600 seconds. The backend checks the current
|
||||
account session, bound nickname and server access before issuing it.
|
||||
|
||||
`admission.rs` has no secret-bearing `Debug` or `Serialize` implementation.
|
||||
Only the final Java child's environment receives:
|
||||
|
||||
- `SHACRAFT_ADMISSION_TICKET`: the one-use ticket ID.
|
||||
- `SHACRAFT_ADMISSION_PRIVATE_KEY`: standard base64 of the Ed25519 PKCS#8
|
||||
private-key-only DER representation accepted by Java's `KeyFactory`.
|
||||
|
||||
No global environment mutation, webview/IPC payload, argument substitution,
|
||||
JVM argfile, settings file or log stores these values. The account operation
|
||||
permit remains held from issuance through spawn so local account switching
|
||||
or logout cannot race the handoff. Missing, disabled or invalid admission
|
||||
responses fail the launch with a visible error; there is no legacy-name or
|
||||
unsigned fallback. In this first version, a consumed or expired ticket needs
|
||||
a fresh game launch from the launcher; transparent in-game reconnect is not
|
||||
implemented.
|
||||
|
||||
The client mod proves possession of the ephemeral private key by signing the
|
||||
server's challenge. The server mod gates world entry on successful backend
|
||||
verification, including one-use consumption and current account/link/access
|
||||
checks. Whitelist enforcement is retained. The 2026-09-10 Aeronautics rollout
|
||||
replaced its separate LoginSystem `/register` and `/login` flow; other servers
|
||||
keep their existing authentication. Old launchers without admission proof
|
||||
cannot join Aeronautics. A required server-only Mixin rejects a duplicate
|
||||
online UUID before vanilla can disconnect the existing player. The client pins
|
||||
the actual game socket to `135.106.154.86:25567`; changing that address requires
|
||||
an explicit mod update. Server source and rollout records live in
|
||||
`/root/shacraft/services/admission-mod` and `docs/admission-2026-09-10.md` on
|
||||
the ShaCraft host.
|
||||
|
||||
This protocol prevents entry without the account's current permission. It
|
||||
does not attest that an original launcher or game binary is unmodified:
|
||||
software running as the same user can read its own process environment, and
|
||||
a compatible client can implement the protocol. Never replace account-bound
|
||||
proof with a shared key embedded in distributed binaries.
|
||||
|
||||
## Planned but not implemented
|
||||
|
||||
@@ -72,6 +147,8 @@ be the system `.minecraft` directory.
|
||||
4. Cancellation, structured logs and a full cold-install/recovery beta on
|
||||
every target OS. Install progress reports bytes or installer work counts
|
||||
depending on the stage; these units are not interchangeable.
|
||||
5. Transparent reconnect after the admission ticket has been consumed or
|
||||
expired. The current implementation requires a new launch.
|
||||
|
||||
Do not represent these as completed features in UI or release notes.
|
||||
|
||||
@@ -105,3 +182,22 @@ The package workflow runs on main pushes or manually and builds Windows
|
||||
x64, Linux x64 and both macOS architectures with named artifacts.
|
||||
Packages are not yet signed release artifacts. Native cold-install and
|
||||
launch tests are required before calling a platform release-ready.
|
||||
|
||||
The local admission checkpoint passed 64 Rust tests (5 live tests ignored),
|
||||
22 UI unit tests, TypeScript/Vite build and a Linux x86-64 release build with
|
||||
`npm run tauri:build -- --no-bundle`. Six browser scenarios with mocked Tauri
|
||||
IPC covered invalid nicknames, deleted sessions, reserved-name errors,
|
||||
successful claims, duplicate clicks and a session revoked during a claim.
|
||||
They also checked modal feedback, Escape preserving the settings drawer and
|
||||
the absence of legacy link polling. These checks used no production account
|
||||
or real Minecraft connection. The Linux output is a dynamically linked
|
||||
binary, not evidence of Windows/macOS support testing.
|
||||
|
||||
Version 0.1.2 also produced unsigned Linux amd64 AppImage and deb packages with
|
||||
`npm run tauri:build -- --bundles appimage,deb`. AppImage extraction and the
|
||||
deb's version/architecture metadata were checked without running the app or
|
||||
installing the package. The build host was Ubuntu 26.04; do not claim support
|
||||
for older Ubuntu releases from this build. For local AppImage packaging,
|
||||
linuxdeploy's GTK plugin needs `librsvg-2.0.pc` from the matching `librsvg2-dev`
|
||||
package. Extracting that package into a temporary build directory and setting
|
||||
`PKG_CONFIG_PATH` supplied the missing metadata without changing host packages.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "shacraft-launcher-ui",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"lucide-react": "1.41.0",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "shacraft-launcher-ui",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+3
-1
@@ -3216,11 +3216,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"ed25519-dalek",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"md-5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -3231,6 +3232,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"url",
|
||||
"zeroize",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shacraft-launcher"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "ShaCraft Minecraft launcher"
|
||||
authors = ["ShaCraft"]
|
||||
license = "MIT"
|
||||
@@ -20,7 +20,9 @@ sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
md-5 = "0.10"
|
||||
base64 = "0.22"
|
||||
ed25519-dalek = "2"
|
||||
ed25519-dalek = { version = "2", features = ["pkcs8"] }
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
tauri = { version = "2", features = [] }
|
||||
url = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls", "json"] }
|
||||
|
||||
@@ -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("зарезервирован"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ShaCraft Launcher",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"identifier": "ru.shacraft.launcher",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+8
-1
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { FeedbackDialog, type Feedback } from './components/FeedbackDialog'
|
||||
import { Library } from './components/Library'
|
||||
import { RecoveryCodesModal } from './components/RecoveryCodesModal'
|
||||
import { PlayDock } from './components/PlayDock'
|
||||
@@ -18,6 +19,7 @@ export function App() {
|
||||
const [selected, setSelected] = useState(servers[0])
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [windowError, setWindowError] = useState<string | null>(null)
|
||||
const [errorFeedback, setErrorFeedback] = useState<Feedback | null>(null)
|
||||
const preferences = useSettings()
|
||||
const session = useAccount()
|
||||
const launcher = useLauncher()
|
||||
@@ -35,6 +37,9 @@ export function App() {
|
||||
(access === 'ready' && (checking || settingsBlocked || !launcher.eventsReady))
|
||||
const repairDisabled = !desktop || busy || checking
|
||||
const error = launcher.game.error ?? preferences.error ?? windowError ?? launcher.environmentError ?? session.error ?? profile?.error ?? null
|
||||
useEffect(() => {
|
||||
if (error) setErrorFeedback({ kind: 'error', title: 'Ошибка лаунчера', message: error })
|
||||
}, [error])
|
||||
|
||||
let label = 'Играть'
|
||||
if (!desktop) label = 'В приложении'
|
||||
@@ -73,6 +78,8 @@ export function App() {
|
||||
<SettingsDrawer open={settingsOpen && !session.recoveryCodes.length} locked={busy} preferences={preferences}
|
||||
session={session} host={launcher.host} java={launcher.java} onClose={closeSettings} />
|
||||
<RecoveryCodesModal codes={session.recoveryCodes} onAcknowledge={session.acknowledgeRecoveryCodes} />
|
||||
<FeedbackDialog feedback={session.recoveryCodes.length ? null : session.feedback ?? errorFeedback}
|
||||
onDismiss={() => { session.dismissFeedback(); setErrorFeedback(null) }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,15 +41,15 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
|
||||
</form>
|
||||
)}
|
||||
{account && !linkedNickname && (
|
||||
<form onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
|
||||
<label className="text-setting">
|
||||
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
|
||||
<input value={nickname} minLength={3} maxLength={16} pattern="[A-Za-z0-9_]{3,16}" required
|
||||
disabled={disabled || session.linking} onChange={(event) => setNickname(event.target.value)} placeholder="Player" />
|
||||
<small>Подтвердите владение ником на сервере Aeronautics.</small>
|
||||
<small>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
|
||||
</label>
|
||||
<button className="setting-row" type="submit" disabled={disabled || session.linking}>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : 'Привязать ник'}</span>
|
||||
<span>{session.linking ? 'Ожидаем подтверждения…' : busy ? 'Создаём проверку…' : 'Привязать ник'}</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export interface Feedback {
|
||||
kind: 'info' | 'success' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export function FeedbackDialog({ feedback, onDismiss }: { feedback: Feedback | null; onDismiss: () => void }) {
|
||||
const dialog = useRef<HTMLDialogElement>(null)
|
||||
const visible = feedback !== null
|
||||
useEffect(() => {
|
||||
const element = dialog.current
|
||||
if (!visible || !element) return
|
||||
const previous = document.activeElement
|
||||
element.showModal()
|
||||
return () => {
|
||||
element.close()
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus()
|
||||
}
|
||||
}, [visible])
|
||||
if (!feedback) return null
|
||||
return (
|
||||
<dialog ref={dialog} className={`feedback-dialog ${feedback.kind}`} aria-labelledby="feedback-title"
|
||||
aria-describedby="feedback-message" onCancel={(event) => { event.preventDefault(); onDismiss() }}
|
||||
onKeyDown={(event) => event.stopPropagation()}>
|
||||
<div aria-live={feedback.kind === 'error' ? 'assertive' : 'polite'} aria-atomic="true">
|
||||
<h2 id="feedback-title">{feedback.title}</h2>
|
||||
<p id="feedback-message">{feedback.message}</p>
|
||||
</div>
|
||||
<button type="button" autoFocus onClick={onDismiss}>Понятно</button>
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { useAccount } from '../hooks/useAccount'
|
||||
function session(): ReturnType<typeof useAccount> {
|
||||
return {
|
||||
account: null, error: null, busy: false, recoveryCodes: [], linkMessage: null,
|
||||
feedback: null, dismissFeedback: () => {},
|
||||
linking: false, linkedNickname: null, authenticate: async () => true,
|
||||
logout: async () => {}, startLink: async () => {}, clearError: () => {},
|
||||
acknowledgeRecoveryCodes: () => {},
|
||||
|
||||
+35
-59
@@ -1,24 +1,23 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Feedback } from '../components/FeedbackDialog'
|
||||
import { createRequestScope, errorMessage } from '../services/async'
|
||||
import { isNative, native } from '../services/native'
|
||||
import { linkedNickname, validCredentials } from '../state/account'
|
||||
import { isValidNickname } from '../state/settings'
|
||||
import type { ShaCraftAccount } from '../types/launcher'
|
||||
|
||||
interface PendingLink {
|
||||
challengeId: number
|
||||
expiresAt: number
|
||||
isCurrent: () => boolean
|
||||
}
|
||||
|
||||
export function useAccount() {
|
||||
// undefined = restoring saved account; null = signed out.
|
||||
const [account, setAccount] = useState<ShaCraftAccount | null | undefined>(isNative() ? undefined : null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const [challenge, setChallenge] = useState<PendingLink | null>(null)
|
||||
const [linkMessage, setLinkMessage] = useState<string | null>(null)
|
||||
const [feedback, setFeedback] = useState<Feedback | null>(null)
|
||||
const reportLink = useCallback((message: string, kind: Feedback['kind'] = 'info') => {
|
||||
setLinkMessage(message)
|
||||
setFeedback({ kind, title: kind === 'error' ? 'Не удалось привязать ник' : 'Привязка игрового ника', message })
|
||||
}, [])
|
||||
const pending = useRef(false)
|
||||
const requests = useRef(createRequestScope())
|
||||
|
||||
@@ -35,44 +34,6 @@ export function useAccount() {
|
||||
return () => { active = false; requests.current.invalidate() }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!challenge) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const current = () => active && challenge.isCurrent()
|
||||
const poll = async () => {
|
||||
if (!current()) return
|
||||
if (Date.now() >= challenge.expiresAt) {
|
||||
setChallenge(null)
|
||||
setLinkMessage('Срок проверки истёк. Начните привязку ещё раз.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await native.linkStatus(challenge.challengeId)
|
||||
if (!current()) return
|
||||
if (result.status === 'verified') {
|
||||
const refreshed = await native.getAccount()
|
||||
if (!current()) return
|
||||
setAccount(refreshed)
|
||||
setChallenge(null)
|
||||
setLinkMessage(linkedNickname(refreshed) ? 'Ник подтверждён.' : 'Не удалось подтвердить привязку. Войдите снова.')
|
||||
} else if (result.status === 'expired' || result.status === 'conflict') {
|
||||
setChallenge(null)
|
||||
setLinkMessage(result.detail || 'Проверка завершилась. Попробуйте ещё раз.')
|
||||
} else {
|
||||
// One request at a time; dispose and logout cancel future polling.
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!current()) return
|
||||
setChallenge(null)
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось проверить ник'))
|
||||
}
|
||||
}
|
||||
timer = setTimeout(() => { void poll() }, 3_000)
|
||||
return () => { active = false; clearTimeout(timer) }
|
||||
}, [challenge])
|
||||
|
||||
const authenticate = async (username: string, password: string, register: boolean) => {
|
||||
if (pending.current || account === undefined) return false
|
||||
if (!validCredentials(username, password)) {
|
||||
@@ -92,7 +53,6 @@ export function useAccount() {
|
||||
const result = await native.authenticate(username, password, register)
|
||||
if (!currentRequest()) return false
|
||||
setAccount(result.account)
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setRecoveryCodes(result.recoveryCodes)
|
||||
return true
|
||||
@@ -109,7 +69,6 @@ export function useAccount() {
|
||||
if (!isNative() || pending.current) return
|
||||
pending.current = true
|
||||
requests.current.invalidate()
|
||||
setChallenge(null)
|
||||
setLinkMessage(null)
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
@@ -126,26 +85,42 @@ export function useAccount() {
|
||||
}
|
||||
|
||||
const startLink = async (nickname: string) => {
|
||||
if (!isNative() || pending.current || challenge || !account) return
|
||||
if (pending.current) return
|
||||
if (!isNative()) {
|
||||
reportLink('Привязка доступна в приложении лаунчера.', 'error')
|
||||
return
|
||||
}
|
||||
if (!account) {
|
||||
reportLink('Войдите в аккаунт ShaCraft, затем повторите привязку.', 'error')
|
||||
return
|
||||
}
|
||||
nickname = nickname.trim()
|
||||
if (!isValidNickname(nickname)) {
|
||||
setLinkMessage('Ник: 3–16 латинских букв, цифр или _')
|
||||
reportLink('Ник: 3–16 латинских букв, цифр или _', 'error')
|
||||
return
|
||||
}
|
||||
pending.current = true
|
||||
setBusy(true)
|
||||
setLinkMessage('Создаём проверку…')
|
||||
setError(null)
|
||||
reportLink('Проверяем аккаунт и закрепляем ник…')
|
||||
requests.current.invalidate()
|
||||
const currentRequest = requests.current.capture()
|
||||
try {
|
||||
const started = await native.startLink(nickname)
|
||||
const refreshed = await native.getAccount()
|
||||
if (!currentRequest()) return
|
||||
setLinkMessage(started.registered_on_server
|
||||
? 'Зайдите на Aeronautics с этим ником и выполните /login.'
|
||||
: 'Зайдите на Aeronautics с этим ником и выполните /register.')
|
||||
setChallenge({ challengeId: started.challenge_id,
|
||||
expiresAt: Date.now() + started.expires_in_seconds * 1000, isCurrent: currentRequest })
|
||||
setAccount(refreshed)
|
||||
if (!refreshed) {
|
||||
reportLink('Сессия завершена или аккаунт удалён. Войдите в ShaCraft снова; если аккаунт удалён, создайте новый.', 'error')
|
||||
return
|
||||
}
|
||||
const linkedAccount = await native.claimNickname(nickname)
|
||||
if (!currentRequest()) return
|
||||
setAccount(linkedAccount)
|
||||
const confirmed = linkedNickname(linkedAccount)
|
||||
reportLink(confirmed ? `Ник ${confirmed} закреплён за аккаунтом. Теперь можно запускать игру.`
|
||||
: 'Не удалось получить закреплённый ник. Войдите снова.', confirmed ? 'success' : 'error')
|
||||
} catch (reason) {
|
||||
setLinkMessage(errorMessage(reason, 'Не удалось начать привязку'))
|
||||
if (currentRequest()) reportLink(errorMessage(reason, 'Не удалось начать привязку'), 'error')
|
||||
} finally {
|
||||
pending.current = false
|
||||
setBusy(false)
|
||||
@@ -153,7 +128,8 @@ export function useAccount() {
|
||||
}
|
||||
|
||||
return {
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: challenge !== null,
|
||||
account, error, busy, recoveryCodes, linkMessage, linking: false,
|
||||
feedback, dismissFeedback: () => setFeedback(null),
|
||||
linkedNickname: linkedNickname(account), authenticate, logout, startLink,
|
||||
clearError: () => setError(null),
|
||||
acknowledgeRecoveryCodes: () => setRecoveryCodes([]),
|
||||
|
||||
@@ -26,6 +26,7 @@ export const native = {
|
||||
accountRequests.enqueue(() => invoke<ShaCraftLoginResult>('shacraft_authenticate', { username, password, register })),
|
||||
logout: () => accountRequests.enqueue(() => invoke<void>('shacraft_logout')),
|
||||
startLink: (nickname: string) => accountRequests.enqueue(() => invoke<LinkChallenge>('shacraft_start_link', { nickname })),
|
||||
claimNickname: (nickname: string) => accountRequests.enqueue(() => invoke<ShaCraftAccount>('shacraft_claim_nickname', { nickname })),
|
||||
linkStatus: (challengeId: number) => accountRequests.enqueue(() => invoke<LinkStatus>('shacraft_link_status', { challengeId })),
|
||||
serverStatus: (profileId: string) => invoke<ServerStatus>('get_server_status', { profileId }),
|
||||
installGame: (profileId: string) => invoke<void>('ensure_game_installed', { profileId }),
|
||||
|
||||
@@ -166,6 +166,14 @@ button:disabled { cursor: default; }
|
||||
.login-code { font-family: 'Unbounded'; font-size: 28px; letter-spacing: .08em; color: var(--green); background: #191f1a; border-radius: 10px; padding: 14px; margin-bottom: 12px; }
|
||||
.login-url { color: var(--ice) !important; font-size: 11px !important; word-break: break-all; }
|
||||
|
||||
.feedback-dialog { width: min(440px, calc(100vw - 40px)); max-height: calc(100vh - 80px); overflow-y: auto; padding: 28px; margin: auto; color: #e9eee9; background: #151d17; border: 1px solid #456348; border-radius: 16px; box-shadow: 0 24px 90px #0009; }
|
||||
.feedback-dialog::backdrop { background: #050a07b8; }
|
||||
.feedback-dialog.error { border-color: #a46c54; }
|
||||
.feedback-dialog h2 { margin: 0 0 14px; font-size: 19px; }
|
||||
.feedback-dialog p { margin: 0 0 24px; color: #c1cbc2; font-size: 14px; line-height: 1.65; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.feedback-dialog button { width: 100%; padding: 12px; border: 0; border-radius: 9px; background: var(--green); color: #102112; font: inherit; font-weight: 650; cursor: pointer; }
|
||||
.feedback-dialog button:focus-visible { outline: 2px solid #e5ffe8; outline-offset: 4px; }
|
||||
|
||||
@media (max-width: 1160px) {
|
||||
.workspace { grid-template-columns: 58px 224px 1fr; }
|
||||
.brand { width: 240px; }
|
||||
|
||||
Reference in New Issue
Block a user