Add Minigames profile and Fabric admission in launcher 0.1.6

This commit is contained in:
Emil
2026-09-13 22:36:20 +03:00
parent 69946e62f1
commit 799fa692ef
41 changed files with 1252 additions and 87 deletions
+16
View File
@@ -13,6 +13,22 @@ concurrency:
cancel-in-progress: true
jobs:
admission-client:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
- name: Check the Minigames admission companion
working-directory: admission-client
run: ./gradlew test build --no-daemon
- uses: actions/upload-artifact@v4
with:
name: shacraft-admission-client
if-no-files-found: error
path: admission-client/build/libs/shacraft-admission-client-0.1.0.jar
check:
runs-on: ubuntu-22.04
steps:
+4
View File
@@ -10,3 +10,7 @@ src-tauri/gen/
*.p12
*.pfx
*.sig
admission-client/.gradle/
admission-client/build/
graphify-out/
+32 -4
View File
@@ -4,8 +4,9 @@
Cross-platform desktop launcher for the ShaCraft Minecraft network. It is a
Tauri 2 application: React/Vite is the UI and Rust owns all filesystem,
network and process-adjacent work. The current production profile is
**Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21).
network and process-adjacent work. Profiles are **Aeronautics** (Minecraft 1.21.1, NeoForge 21.1.248, Java 21)
and **Minigames** (Minecraft 26.2, Fabric 0.19.5, Java 25). See the staged
Minigames integration record below; published launcher status is separate.
This repository owns the launcher only. The server-side API and published
payload are in `/root/shacraft` on the ShaCraft host; see
@@ -21,8 +22,9 @@ payload are in `/root/shacraft` on the ShaCraft host; see
## Trust model
- The only supported remote profile manifest endpoint is
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest`.
- The only supported remote profile manifest endpoints are the fixed
`https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest` and
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest`.
The read-only Aeronautics player-count endpoint
`https://shacraft.ru/api/online/aoc` is also hardcoded in `remote.rs`; it
is display-only and is never allowed to influence downloads or launching.
@@ -217,3 +219,29 @@ Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
signed-manifest retrieval and download/repair/restoration of the admission jar
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
pending exact error/log; this is not a verified macOS fix or desktop UI test.
## Minigames integration (2026-09-13, staged)
- Native profile mapping is fixed: aeronautics → aoc; minigames → minigames.
Both display/claim the canonical existing aoc nickname. The backend enforces
the current shared aoc subscription and whitelist for Minigames as well.
Ticket requests and responses remain bound to the selected server; never
accept an aoc ticket as a Minigames ticket.
- `fabric.rs` adds independent exact HTTPS domains `meta.fabricmc.net` and
`maven.fabricmc.net`. It verifies profile identity/parent/main class, bounded
metadata, portable Maven coordinates, hashes and sizes before the existing
atomic library installer. Unknown loaders now fail manifest validation.
- Minigames launches with a native-owned Quick Play endpoint
`135.106.154.86:25568`. The manifest cannot choose a game destination.
- `admission-client/` owns the small client-only Fabric 26.2 companion. Its
configuration-phase proof uses `minigames` in the existing Ed25519 transcript,
verifies the actual socket, canonical nickname and nonce, and signs once per
process. Only a fresh launch can retry a consumed ticket. Java receives only
the ephemeral ticket and private key in its child environment, never the
website session/password. Keep server verification on Paper before world
entry with an early duplicate UUID guard.
- The standalone Paper admission adapter and backend remain server-project
responsibilities. Do not put map/SMASH source into this launcher repository.
- Production updater publication requires a separately built, monotonically
newer launcher release and existing operator signatures. Source tests or a
client jar alone do not update installed 0.1.5 launchers.
+9 -7
View File
@@ -3,13 +3,15 @@
Кроссплатформенный Tauri 2 лаунчер для [ShaCraft](https://shacraft.ru/):
React/TypeScript интерфейс, Rust — файлы, сеть и запуск процессов.
Реализованы подписанная синхронизация Aeronautics, проверка/восстановление
модов и конфигурации, Java discovery/provisioning, bootstrap Minecraft и
NeoForge, настройки памяти и ника, обработка установки/запуска/выхода.
Реализованы отдельные профили Aeronautics (Minecraft 1.21.1, NeoForge, Java 21)
и Minigames (Minecraft 26.2, Fabric, Java 25), подписанная синхронизация файлов,
проверка/восстановление модов и конфигурации, установка игры и Java, настройки
памяти, обработка установки/запуска/выхода. Minigames добавлен в кандидате 0.1.6;
статус публикации фиксируется в [записи релиза](docs/release-0.1.6.md).
Вход выполняется через аккаунт ShaCraft — тот же, что на сайте. Игровой ник
берётся только из подтверждённой привязки Aeronautics, а не из редактируемых
локальных настроек. Пароли не сохраняются; сессию можно отозвать.
общий для обоих профилей и берётся только из подтверждённой привязки `aoc`.
Тикет входа привязан к выбранному серверу; локальные настройки не выбирают личность. Пароли не сохраняются; сессию можно отозвать.
Microsoft OAuth-модуль сохранён отдельно, но не используется текущим
сценарием запуска; для его активации потребуются client ID и API approval.
@@ -52,8 +54,8 @@ push/PR; workflow на main-push/ручном запуске собирает Wi
Для deb система запрашивает права администратора; пароль не передаётся лаунчеру.
Deb 0.1.3 и ниже нужно один раз обновить вручную до 0.1.4. Dev-бинарник
обновляется вручную. С версии 0.1.2 нужен ручной переход на новый AppImage.
Пакеты Windows/macOS
с этим механизмом ещё требуют публикации и проверки установки.
Пакеты Windows/macOS 0.1.5 опубликованы. Проверка реальной установки на каждой
платформе остаётся отдельной от сборки и автоматических тестов.
## Навигация
+47
View File
@@ -0,0 +1,47 @@
# ShaCraft Minigames admission client
Client-only Fabric companion for Minecraft 26.2, Java 25, Fabric Loader 0.19.5
and Fabric API 0.160.0+26.2. This is account admission, not an anti-cheat or a
proof that the original launcher binary is running.
Build with Java 25: `./gradlew test build --no-daemon`.
Output: `build/libs/shacraft-admission-client-0.1.0.jar`.
Publish that jar and the pinned Fabric API jar in the signed Minigames profile.
The native launcher supplies `SHACRAFT_ADMISSION_TICKET` and
`SHACRAFT_ADMISSION_PRIVATE_KEY` only to the final Java child environment.
The client accepts a CONFIGURATION payload on `shacraft_admission:challenge`
with three Minecraft UTF strings: server ID (16), nickname (16), nonce (43).
It verifies server `minigames`, the exact current game nickname and actual
socket `135.106.154.86:25568`, then signs once with the ephemeral Ed25519 key.
The response on `shacraft_admission:proof` contains ticket (43) and standard
Base64 signature (88). The transcript has no final newline:
```
shacraft-admission-v1
{ticket_id}
minigames
{mc_username}
{nonce}
```
The private key and website session never go onto the Minecraft wire. Errors
are redacted. A fresh game launch is needed for another connection after a
proof has been sent. `SHACRAFT_ADMISSION_ALLOW_LOOPBACK=1` additionally permits
literal loopback sockets for isolated tests; normal releases do not set it.
Paper must fail closed before world entry and reject unauthenticated duplicate
UUIDs before the vanilla duplicate-player eviction. The backend checks the
current shared aoc account access and atomically redeems the server-bound ticket.
Three unit tests cover exact signature binding, invalid/cross-server fields
and socket allowlisting. The unchanged production companion also passed actual Minecraft 26.2
configuration negotiation and entered a local Paper lobby with a synthetic
backend ticket; see [receipt](../docs/verification/minigames-fabric-2026-09-13.json).
The public server and other platforms still require their own rollout checks.
The client explicitly advertises its single challenge receiver with vanilla
`minecraft:register` at the start of configuration. Fabric normally waits for
the server's registration first, while Paper gates plugin sends on that client
advertisement. This bootstrap uses the pinned Fabric API's RegistrationPayload;
update it and rerun live negotiation checks when upgrading Fabric API. It is
queued after INIT so vanilla has switched outbound protocol to CONFIGURATION.
+37
View File
@@ -0,0 +1,37 @@
plugins {
id 'net.fabricmc.fabric-loom' version "${loom_version}"
}
repositories { mavenCentral() }
loom {
splitEnvironmentSourceSets()
mods {
'shacraft_admission' {
sourceSet sourceSets.main
sourceSet sourceSets.client
}
}
}
dependencies {
minecraft "com.mojang:minecraft:${project.minecraft_version}"
implementation "net.fabricmc:fabric-loader:${project.loader_version}"
implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}"
testImplementation platform('org.junit:junit-bom:5.12.2')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
processResources {
inputs.property 'version', project.version
filesMatching('fabric.mod.json') { expand version: project.version }
}
tasks.withType(JavaCompile).configureEach { options.release = 25 }
java { toolchain.languageVersion = JavaLanguageVersion.of(25); withSourcesJar() }
test { useJUnitPlatform() }
// Pure HTTP/validation tests use the same client implementation without launching Minecraft.
sourceSets.test.compileClasspath += sourceSets.client.output
sourceSets.test.runtimeClasspath += sourceSets.client.output
+9
View File
@@ -0,0 +1,9 @@
org.gradle.jvmargs=-Xmx2G
org.gradle.parallel=false
org.gradle.configuration-cache=false
minecraft_version=26.2
loader_version=0.19.5
loom_version=1.17.20
fabric_api_version=0.160.0+26.2
version=0.1.0
group=ru.shacraft
Binary file not shown.
@@ -0,0 +1,8 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
distributionSha256Sum=bafc141b619ad6350fd975fc903156dd5c151998cc8b058e8c1044ab5f7b031f
networkTimeout=30000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+8
View File
@@ -0,0 +1,8 @@
pluginManagement {
repositories {
maven { url = 'https://maven.fabricmc.net/' }
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'shacraft-admission-client'
@@ -0,0 +1,57 @@
package ru.shacraft.admission;
import java.net.InetSocketAddress;
import java.util.concurrent.atomic.AtomicBoolean;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationNetworking;
import net.fabricmc.fabric.api.client.networking.v1.ClientConfigurationConnectionEvents;
import net.fabricmc.fabric.impl.networking.RegistrationPayload;
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
import java.util.List;
import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry;
import net.minecraft.network.chat.Component;
/** The account session remains in the native launcher, never in Minecraft. */
public final class ClientAdmission implements ClientModInitializer {
private static final AtomicBoolean USED = new AtomicBoolean();
@Override public void onInitializeClient() {
PayloadTypeRegistry.clientboundConfiguration().register(AdmissionPayloads.Challenge.TYPE, AdmissionPayloads.Challenge.CODEC);
PayloadTypeRegistry.serverboundConfiguration().register(AdmissionPayloads.Proof.TYPE, AdmissionPayloads.Proof.CODEC);
ClientConfigurationNetworking.registerGlobalReceiver(AdmissionPayloads.Challenge.TYPE, ClientAdmission::challenge);
// Paper waits for vanilla channel advertisement, while Fabric normally waits
// for the server's registration first. Bootstrap our one fixed receiver.
// INIT runs in the listener constructor; schedule() queues until after vanilla
// has switched the outbound protocol from LOGIN to CONFIGURATION.
ClientConfigurationConnectionEvents.INIT.register((listener, client) -> client.schedule(() ->
listener.send(new ServerboundCustomPayloadPacket(new RegistrationPayload(
RegistrationPayload.REGISTER, List.of(AdmissionPayloads.Challenge.TYPE.id()))))));
}
private static void challenge(AdmissionPayloads.Challenge challenge, ClientConfigurationNetworking.Context context) {
var connection = context.packetContext().orElseThrow(net.fabricmc.fabric.api.networking.v1.context.PacketContext.CONNECTION);
boolean loopback = "1".equals(System.getenv("SHACRAFT_ADMISSION_ALLOW_LOOPBACK"));
if (!(connection.getRemoteAddress() instanceof InetSocketAddress remote)
|| remote.getAddress() == null
|| !AdmissionProof.allowedTarget(remote.getAddress().getHostAddress(), remote.getPort(), loopback)
|| !AdmissionProof.SERVER_ID.equals(challenge.serverId())
|| !context.client().getUser().getName().equals(challenge.nickname())
|| !AdmissionProof.validOpaque(challenge.nonce())) {
deny(context); return;
}
String ticket = System.getenv("SHACRAFT_ADMISSION_TICKET");
String privateKey = System.getenv("SHACRAFT_ADMISSION_PRIVATE_KEY");
if (!AdmissionProof.validOpaque(ticket) || !USED.compareAndSet(false, true)) {
deny(context); return;
}
try {
String signature = AdmissionProof.sign(privateKey, ticket, challenge.serverId(), challenge.nickname(), challenge.nonce());
context.responseSender().sendPacket(new AdmissionPayloads.Proof(ticket, signature));
} catch (Exception invalidKey) { deny(context); }
}
private static void deny(ClientConfigurationNetworking.Context context) {
context.responseSender().disconnect(Component.literal(
"Не удалось подтвердить вход ShaCraft. Закройте игру и запустите её заново через ShaCraft Launcher."));
}
}
@@ -0,0 +1,28 @@
package ru.shacraft.admission;
import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.StreamCodec;
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier;
public final class AdmissionPayloads {
private AdmissionPayloads() {}
public record Challenge(String serverId, String nickname, String nonce) implements CustomPacketPayload {
public static final Type<Challenge> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "challenge"));
public static final StreamCodec<FriendlyByteBuf, Challenge> CODEC = StreamCodec.of(
(buffer, value) -> { buffer.writeUtf(value.serverId, 16); buffer.writeUtf(value.nickname, 16); buffer.writeUtf(value.nonce, 43); },
buffer -> new Challenge(buffer.readUtf(16), buffer.readUtf(16), buffer.readUtf(43)));
@Override public Type<Challenge> type() { return TYPE; }
@Override public String toString() { return "AdmissionChallenge[redacted]"; }
}
public record Proof(String ticket, String signature) implements CustomPacketPayload {
public static final Type<Proof> TYPE = new Type<>(Identifier.fromNamespaceAndPath("shacraft_admission", "proof"));
public static final StreamCodec<FriendlyByteBuf, Proof> CODEC = StreamCodec.of(
(buffer, value) -> { buffer.writeUtf(value.ticket, 43); buffer.writeUtf(value.signature, 88); },
buffer -> new Proof(buffer.readUtf(43), buffer.readUtf(88)));
@Override public Type<Proof> type() { return TYPE; }
@Override public String toString() { return "AdmissionProof[redacted]"; }
}
}
@@ -0,0 +1,67 @@
package ru.shacraft.admission;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.regex.Pattern;
/** The only client credential is an ephemeral private key supplied by the launcher. */
public final class AdmissionProof {
public static final String SERVER_ID = "minigames";
private static final Pattern OPAQUE = Pattern.compile("[A-Za-z0-9_-]{43}");
private static final Pattern NICKNAME = Pattern.compile("[A-Za-z0-9_]{3,16}");
private AdmissionProof() {}
public static boolean validOpaque(String value) {
return value != null && OPAQUE.matcher(value).matches();
}
public static byte[] transcript(String ticket, String serverId, String nickname, String nonce) {
if (!validOpaque(ticket) || !validOpaque(nonce) || !SERVER_ID.equals(serverId)
|| nickname == null || !NICKNAME.matcher(nickname).matches()) {
throw new IllegalArgumentException("Invalid admission challenge");
}
return ("shacraft-admission-v1\n" + ticket + "\n" + serverId + "\n"
+ nickname + "\n" + nonce).getBytes(StandardCharsets.UTF_8);
}
public static String sign(String encodedPrivateKey, String ticket, String serverId,
String nickname, String nonce) throws Exception {
if (encodedPrivateKey == null || encodedPrivateKey.length() > 256) {
throw new IllegalArgumentException("Missing admission key");
}
byte[] encoded = Base64.getDecoder().decode(encodedPrivateKey);
try {
PrivateKey key = KeyFactory.getInstance("Ed25519")
.generatePrivate(new PKCS8EncodedKeySpec(encoded));
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(key);
signer.update(transcript(ticket, serverId, nickname, nonce));
return Base64.getEncoder().encodeToString(signer.sign());
} finally {
java.util.Arrays.fill(encoded, (byte) 0);
}
}
public static boolean validSignature(String value) {
if (value == null || value.length() != 88) return false;
try {
byte[] decoded = Base64.getDecoder().decode(value);
return decoded.length == 64 && Base64.getEncoder().encodeToString(decoded).equals(value);
} catch (IllegalArgumentException invalid) {
return false;
}
}
public static boolean allowedTarget(String host, int port, boolean allowLoopback) {
if (host == null) return false;
if (allowLoopback && (host.equals("127.0.0.1") || host.equals("::1") || host.equals("[::1]") || host.equals("0:0:0:0:0:0:0:1"))) {
return port > 0 && port <= 65535;
}
return port == 25568 && (host.equalsIgnoreCase("shacraft.ru") || host.equals("135.106.154.86"));
}
}
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"id": "shacraft_admission",
"version": "${version}",
"name": "ShaCraft Minigames Admission",
"description": "Account-bound admission to ShaCraft Minigames.",
"environment": "client",
"entrypoints": { "client": ["ru.shacraft.admission.ClientAdmission"] },
"depends": { "fabricloader": ">=0.19.5", "minecraft": "26.2", "java": ">=25", "fabric-networking-api-v1": "*" }
}
@@ -0,0 +1,32 @@
package ru.shacraft.admission;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
import java.security.KeyPairGenerator;
import java.security.Signature;
import java.util.Base64;
import org.junit.jupiter.api.Test;
class AdmissionProofTest {
private static final String TICKET = "A".repeat(43), NONCE = "B".repeat(43);
@Test void signsExactServerBoundTranscript() throws Exception {
var pair=KeyPairGenerator.getInstance("Ed25519").generateKeyPair();
String signed=AdmissionProof.sign(Base64.getEncoder().encodeToString(pair.getPrivate().getEncoded()), TICKET,"minigames","Pilot_1",NONCE);
var verifier=Signature.getInstance("Ed25519"); verifier.initVerify(pair.getPublic());
verifier.update(("shacraft-admission-v1\n"+TICKET+"\nminigames\nPilot_1\n"+NONCE).getBytes(StandardCharsets.UTF_8));
assertTrue(verifier.verify(Base64.getDecoder().decode(signed)));
verifier.update(AdmissionProof.transcript(TICKET,"minigames","Other",NONCE));
assertFalse(verifier.verify(Base64.getDecoder().decode(signed)));
}
@Test void rejectsCrossServerAndMalformedFields() {
for (String[] v:new String[][]{{TICKET,"aoc","Pilot",NONCE},{TICKET,"minigames","Bad\nName",NONCE},{"bad","minigames","Pilot",NONCE},{TICKET,"minigames","Pilot","bad"}})
assertThrows(IllegalArgumentException.class,()->AdmissionProof.transcript(v[0],v[1],v[2],v[3]));
}
@Test void trustsOnlyMinigamesSocketAndExplicitLocalTests() {
assertTrue(AdmissionProof.allowedTarget("135.106.154.86",25568,false));
assertFalse(AdmissionProof.allowedTarget("135.106.154.86",25567,false));
assertFalse(AdmissionProof.allowedTarget("127.0.0.1",25568,false));
assertTrue(AdmissionProof.allowedTarget("127.0.0.1",25570,true));
assertFalse(AdmissionProof.allowedTarget("attacker.invalid",25568,true));
}
}
+18
View File
@@ -90,3 +90,21 @@ URL — cannot redirect a download to an attacker-controlled host in any of
these domains. When adding a new game-related download, verify its host is
one of the ones above (or add a new hardcoded constant following the same
pattern) rather than accepting a URL from anywhere else.
## 5. Fabric (`fabric.rs`, Minigames)
Metadata comes only from `https://meta.fabricmc.net/v2/versions/loader/` for
manifest-selected, validated version identifiers. Profile ID, parent and
KnotClient main class must match. At most 512 KiB metadata and 32 libraries
are accepted. Library URLs are constructed only below
`https://maven.fabricmc.net/` from portable three-part Maven coordinates;
other metadata origins are rejected. Libraries require a 40-hex SHA-1 and a
positive size up to 64 MiB. When Fabric metadata omits either for its loader
jar, the fixed Maven's `.sha1` sidecar and HEAD provide them. Existing verified
downloads perform the hash/size check and atomic rename. The ShaCraft signed
manifest cannot select Fabric metadata URLs, repositories or launch targets.
Minecraft 26.2's official version metadata requires Java 25. `runtime.rs`
already provisions a separate Adoptium Java 25 runtime without changing the
Aeronautics Java 21 runtime. Client companion mods and Fabric API are separately
approved ShaCraft managed files in the signed Minigames profile.
+40 -1
View File
@@ -2,7 +2,7 @@
## Current capability
The launcher persists local settings, synchronises Aeronautics mod/config
The launcher persists local settings, synchronises profile 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 current
@@ -320,3 +320,42 @@ Linux Java 21 build and 8 mod tests pass. An opt-in native live test verifies
signed-manifest retrieval and download/repair/restoration of the admission jar
only in a temporary directory. Mac 0.1.5 connection failure remains unclassified
pending exact error/log; this is not a verified macOS fix or desktop UI test.
## Minigames alongside Aeronautics (2026-09-13)
The new native allowlist adds profile `minigames` at
`https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest` and
its display-only count at `https://shacraft.ru/api/online/minigames`.
Aeronautics remains a separate profile and keeps its previous managed files.
Minigames uses Minecraft 26.2, Fabric Loader 0.19.5, Java 25 and its own
`profiles/minigames` game directory. The signed payload supplies Fabric API
0.160.0+26.2 and `mods/shacraft-admission-client-0.1.0.jar`; it never supplies
Paper, the server plugins, maps, credentials or game-download URLs.
`fabric.rs` obtains an exact parent/loader profile from fixed Fabric metadata,
checks its identity and KnotClient entry point, and converts bounded Maven
library entries into the existing verified library contract. Each artifact
URL is constructed from a validated coordinate below fixed Fabric Maven;
metadata-supplied alternative origins are rejected. SHA-1 and size come from
Fabric metadata, or the same Maven's hash sidecar and HEAD for the loader jar.
Java provisioning already accepts exactly Java 25. Automatic installation on
a platform still requires a real cold-install check on that platform.
Both profiles claim/display the canonical `aoc` nickname. Only the native
profile mapping determines ticket `server_id` (`aoc` or `minigames`), and the
response must match that exact server. The backend is responsible for shared
access checks at both issuance and redemption. Existing account sessions and
settings need no migration. No copied subscription/whitelist grant is trusted.
Minigames adds native `--quickPlayMultiplayer 135.106.154.86:25568` at launch.
The client-only Fabric companion validates the actual socket target and signs
the existing configuration challenge with `minigames` in the transcript.
Paper performs verification before entry; its early duplicate UUID gate must
run before vanilla would evict the existing player. There is no proxy and no
client-side shared secret. One ticket is used for one game connection; a fresh
launcher start is required after expiry, consumption or a failed proof attempt.
The integration is staged until server authentication, signed profile payload,
and a newer signed launcher release are deployed and checked together. The
existing public 0.1.5 binary cannot select the new profile by a website-only
catalog change. Preserve both catalog entries when publishing either profile.
+105
View File
@@ -0,0 +1,105 @@
# Launcher 0.1.6 candidate
Adds a separate Minigames profile (Minecraft 26.2 / Fabric 0.19.5 / Java 25)
while keeping Aeronautics and its installed profile intact. Both use the
existing canonical aoc nickname and shared access. Tickets remain bound to
the selected server. Minigames connects to 135.106.154.86:25568 and proves
account admission during configuration before world entry.
Local checks: 87 native tests, 33 UI tests, TypeScript/Vite, three Java client
proof tests, an official Fabric metadata resolution check and the Linux native
release build. The [real Fabric/Paper smoke](verification/minigames-fabric-2026-09-13.json)
passed with a synthetic account: the unchanged production companion entered
the lobby and the backend consumed its Minigames ticket. Cross-platform CI
and production rollout results must be recorded before calling this published. No updater feed has
been changed by preparing this candidate.
## Build contract
Git remote: `git@github.com:emil28092005/shacraft-launcher.git`.
The production admission line is branch `codex/launcher-updater`; `main` is a
divergent unreleased prototype and must remain unchanged for this release.
Push the reviewed commit to the production branch, then dispatch
`gh workflow run build.yml --ref codex/launcher-updater --repo emil28092005/shacraft-launcher`.
The check workflow runs on branch pushes. Verify each run's head SHA equals the
reviewed commit before downloading artifacts. A main push also triggers the
build matrix, but is not the publication path for this candidate.
CI publishes unsigned artifacts named:
- `shacraft-launcher-linux-x64`: AppImage and deb.
- `shacraft-launcher-windows-x64`: NSIS exe and MSI.
- `shacraft-launcher-macos-arm64`: aarch64 app.tar.gz and DMG.
- `shacraft-launcher-macos-x64`: x86_64 app.tar.gz and DMG.
`.github/workflows/check.yml` additionally tests the Java 25 admission client
and uploads `shacraft-admission-client`. It has no production credentials.
Download the artifacts from the checked run at the exact reviewed commit with
`gh run download RUN_ID --repo emil28092005/shacraft-launcher --dir STAGING`.
## Signing and publication
Stage renamed ASCII filenames below a local downloads root, for example
`/tmp/shacraft-release-0.1.6/downloads/0.1.6/`. Preserve already published
0.1.5 bytes. Expected updater filenames:
- `ShaCraft.Launcher_0.1.6_amd64.AppImage`
- `ShaCraft.Launcher_0.1.6_amd64.deb`
- `ShaCraft.Launcher_0.1.6_x64-setup.exe`
- `ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz`
- `ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz`
MSI and DMG are additional manual downloads; the updater uses EXE and app.tar.gz.
Inspect package versions, architecture and contents before signing. Sign each
chosen file with the existing local operator key:
```bash
npm run tauri -- signer sign --private-key-path /home/emil/.local/share/shacraft-updater/production.key ARTIFACT
```
The key file is mode 0600 and remains local. Never read its contents into logs,
copy it to CI/server or substitute a different signing identity. Existing
`production.key.pub` is sufficient for every later verification/publication.
After creating a UTF-8 release notes file, prepare the payload:
```bash
python3 scripts/publish_launcher_update.py prepare \
--version 0.1.6 \
--downloads-root /tmp/shacraft-release-0.1.6/downloads \
--artifact linux-x86_64=ShaCraft.Launcher_0.1.6_amd64.AppImage \
--artifact linux-x86_64-appimage=ShaCraft.Launcher_0.1.6_amd64.AppImage \
--artifact linux-x86_64-deb=ShaCraft.Launcher_0.1.6_amd64.deb \
--artifact windows-x86_64=ShaCraft.Launcher_0.1.6_x64-setup.exe \
--artifact darwin-aarch64=ShaCraft.Launcher_0.1.6_aarch64.app.tar.gz \
--artifact darwin-x86_64=ShaCraft.Launcher_0.1.6_x86_64.app.tar.gz \
--notes-file /tmp/shacraft-release-0.1.6/notes.txt \
--public-key /home/emil/.local/share/shacraft-updater/production.key.pub \
--payload /tmp/shacraft-release-0.1.6/release.payload.json
npm run tauri -- signer sign \
--private-key-path /home/emil/.local/share/shacraft-updater/production.key \
/tmp/shacraft-release-0.1.6/release.payload.json
```
Upload only public packages, signatures, payload, public key and publisher.
Server downloads root is `/root/shacraft/caddy/www/downloads/shacraft-launcher`;
public artifact URLs are `https://shacraft.ru/downloads/shacraft-launcher/0.1.6/`
followed by the checked filename. Preserve the old feed before publication.
With the exact staged server paths, run the existing publisher first with
`--dry-run`, then without it:
```bash
python3 publish_launcher_update.py publish \
--downloads-root /root/shacraft/caddy/www/downloads/shacraft-launcher \
--public-key PUBLIC_KEY_FILE \
--payload SIGNED_PAYLOAD_FILE \
--signature PAYLOAD_SIGNATURE_FILE \
--output /root/shacraft/data/launcher/updates/stable.json \
--dry-run
```
Publication depends on the signed Minigames profile containing the final client
and Fabric API jars, the healthy Paper admission gate, and the shared-access
backend endpoints being available. Verify public HTTPS package hashes, feed
signatures and an actual client admission before updating the website buttons.
OS Authenticode/Apple notarization and cold installations on other platforms
remain distinct from successful native CI/builds.
@@ -0,0 +1,33 @@
{
"recorded_at": "2026-09-13T19:32:59.436884+00:00",
"result": "joined_world",
"client": "Minecraft 26.2 / Fabric Loader 0.19.5 / Fabric API 0.160.0+26.2",
"java": "25.0.2",
"client_jar_sha256": "3335626b7c8fdd233e8531ad382594398c7df48842b1b919934d1154dd4ba8f1",
"client_jar_size": 10667,
"profile": "minigames",
"synthetic_player": "AdmissionPilot",
"endpoint": "127.0.0.1:25608",
"environment": "isolated Xvfb :95; separate temporary game directory; synthetic account/ticket; explicit local socket test flag",
"server_evidence": [
"[22:32:02 INFO]: AdmissionPilot joined the game",
"[22:32:02 INFO]: AdmissionPilot[/127.0.0.1:54602] logged in with entity id 77 at ([minecraft:shacraft_lobby_v2]0.5, 96.0, 43.5)"
],
"verified": [
"production companion jar bytes unchanged",
"queued minecraft:register during configuration",
"server-bound Ed25519 response accepted",
"actual vanilla client entered lobby world"
],
"not_tested": [
"production public endpoint login",
"Windows/macOS cold installation",
"full GUI launcher install/account flow"
],
"notes": "Offline client emits Microsoft profile-certificate HTTP401; admission and world entry succeeded. Real account/session/password not used.",
"backend_evidence": {
"source": "backend agent read-only synthetic SQLite query",
"issued_minigames_tickets_for_player": 1,
"consumed_minigames_tickets_for_player": 1
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "shacraft-launcher-ui",
"version": "0.1.5",
"version": "0.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "shacraft-launcher-ui",
"version": "0.1.5",
"version": "0.1.6",
"dependencies": {
"@tauri-apps/api": "2.11.1",
"lucide-react": "1.41.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "shacraft-launcher-ui",
"license": "MIT",
"private": true,
"version": "0.1.5",
"version": "0.1.6",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -3361,7 +3361,7 @@ dependencies = [
[[package]]
name = "shacraft-launcher"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shacraft-launcher"
version = "0.1.5"
version = "0.1.6"
description = "ShaCraft Minecraft launcher"
authors = ["ShaCraft"]
license = "MIT"
+37 -7
View File
@@ -19,11 +19,21 @@ 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>,
}
@@ -43,13 +53,15 @@ pub(crate) struct TicketResponse {
}
pub(crate) struct Admission {
server_id: &'static str,
ticket_id: Zeroizing<String>,
private_key: Zeroizing<String>,
identity: PlayerIdentity,
}
impl AdmissionKey {
pub(crate) fn generate() -> Result<Self, &'static str> {
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(|_| "Не удалось создать защищённый ключ входа. Повторите запуск лаунчера.")?;
@@ -65,6 +77,7 @@ impl AdmissionKey {
.to_pkcs8_der()
.map_err(|_| "Не удалось подготовить защищённый ключ входа.")?;
Ok(Self {
server_id,
public_key,
private_key: Zeroizing::new(STANDARD.encode(encoded.as_bytes())),
})
@@ -72,7 +85,7 @@ impl AdmissionKey {
pub(crate) fn request(&self) -> TicketRequest<'_> {
TicketRequest {
server_id: SERVER_ID,
server_id: self.server_id,
public_key: &self.public_key,
}
}
@@ -92,12 +105,13 @@ impl AdmissionKey {
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_');
if !valid_ticket
|| !valid_nickname
|| response.server_id != SERVER_ID
|| 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 {
@@ -108,6 +122,7 @@ impl AdmissionKey {
}
impl Admission {
pub(crate) fn server_id(&self) -> &str { self.server_id }
pub(crate) fn identity(&self) -> &PlayerIdentity {
&self.identity
}
@@ -132,10 +147,25 @@ mod tests {
}
}
#[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().unwrap();
let other = AdmissionKey::generate().unwrap();
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);
@@ -173,7 +203,7 @@ mod tests {
for mutate in mutations {
let mut payload = response();
mutate(&mut payload);
assert!(AdmissionKey::generate().unwrap().bind(payload).is_err());
assert!(AdmissionKey::generate("aoc").unwrap().bind(payload).is_err());
}
}
@@ -181,7 +211,7 @@ mod tests {
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 admission = AdmissionKey::generate("aoc").unwrap().bind(response()).unwrap();
let mut command = Command::new("java");
command
.arg("-Xmx6144M")
+5 -2
View File
@@ -1,6 +1,6 @@
use super::data_dir;
use crate::{
java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
fabric, java, launch, manifest, mojang, neoforge, operations::LauncherOperations, remote, runtime,
settings, shacraft_account,
};
use reqwest::blocking::Client;
@@ -56,6 +56,9 @@ fn resolve_merged_version(
)
.map_err(|error| error.to_string())?;
mojang::merge_versions(&vanilla, Some(&neoforge_version)).map_err(|error| error.to_string())
} else if manifest.minecraft.loader.kind == "fabric" {
let child = fabric::fetch_profile(&manifest.minecraft.version, &manifest.minecraft.loader.version)?;
mojang::merge_versions(&vanilla, Some(&child)).map_err(|error| error.to_string())
} else {
mojang::merge_versions(&vanilla, None).map_err(|error| error.to_string())
}
@@ -205,7 +208,7 @@ pub(crate) async fn launch_game(
// 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())?;
shacraft_account::issue_admission(&data_dir, crate::admission::server_for_profile(&profile_id)?).map_err(|error| error.to_string())?;
let request = launch::LaunchRequest {
java_executable: Path::new(&java_install.executable),
game_dir: &game_dir,
+220
View File
@@ -0,0 +1,220 @@
//! Fabric metadata and Maven are fixed, independent game trust domains.
//! The signed ShaCraft manifest selects versions, never arbitrary loader URLs.
use crate::mojang::{Artifact, LibraryDownloads, VersionJson};
use reqwest::blocking::Client;
use serde::Deserialize;
use std::{io::Read, time::Duration};
pub(crate) const MAVEN_HOST: &str = "maven.fabricmc.net";
const HOSTS: [&str; 2] = ["meta.fabricmc.net", MAVEN_HOST];
const MAX_PROFILE: usize = 512 * 1024;
#[derive(Deserialize)]
struct FabricLibrary {
name: String,
url: String,
sha1: Option<String>,
size: Option<u64>,
}
fn coordinate_path(name: &str) -> Result<String, String> {
let pieces: Vec<_> = name.split(':').collect();
if pieces.len() != 3
|| pieces.iter().any(|p| {
!crate::manifest::is_portable_component(p)
|| *p == "."
|| *p == ".."
|| !p
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
})
|| pieces[0].split('.').any(|p| !crate::manifest::is_portable_component(p))
{
return Err("Invalid Fabric library coordinate".into());
}
Ok(format!(
"{}/{}/{}/{}-{}.jar",
pieces[0].replace('.', "/"),
pieces[1],
pieces[2],
pieces[1],
pieces[2]
))
}
fn get(client: &Client, url: &str, limit: usize) -> Result<Vec<u8>, String> {
let response = client
.get(url)
.send()
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
response
.take((limit + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|e| e.to_string())?;
if bytes.len() > limit {
return Err("Fabric metadata exceeds its size limit".into());
}
Ok(bytes)
}
pub(crate) fn fetch_profile(minecraft: &str, loader: &str) -> Result<VersionJson, String> {
let client =
crate::trusted_http::client(&HOSTS, Duration::from_secs(30)).map_err(|e| e.to_string())?;
// Defence in depth: these values normally already passed manifest validation.
if [minecraft, loader].iter().any(|v| {
!crate::manifest::is_portable_component(v)
|| v.contains('/')
|| !v
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._+-".contains(&c))
}) {
return Err("Invalid Fabric version".into());
}
let bytes = get(
&client,
&format!("https://meta.fabricmc.net/v2/versions/loader/{minecraft}/{loader}/profile/json"),
MAX_PROFILE,
)?;
normalize(&client, &bytes, minecraft, loader)
}
fn normalize(
client: &Client,
bytes: &[u8],
minecraft: &str,
loader: &str,
) -> Result<VersionJson, String> {
// Flattening libraries would consume the same key twice; parse the two views explicitly.
let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
let parent = value.get("inheritsFrom").and_then(|v| v.as_str());
let mut version: VersionJson =
serde_json::from_value(value.clone()).map_err(|e| e.to_string())?;
if parent != Some(minecraft)
|| version.id != format!("fabric-loader-{loader}-{minecraft}")
|| version.main_class != "net.fabricmc.loader.impl.launch.knot.KnotClient"
{
return Err("Fabric profile identity mismatch".into());
}
let artifacts: Vec<FabricLibrary> =
serde_json::from_value(value["libraries"].clone()).map_err(|e| e.to_string())?;
if artifacts.is_empty() || artifacts.len() > 32 {
return Err("Invalid Fabric library count".into());
}
for (library, artifact) in version.libraries.iter_mut().zip(artifacts) {
if artifact.url != "https://maven.fabricmc.net/" {
return Err("Untrusted Fabric Maven URL".into());
}
let path = coordinate_path(&artifact.name)?;
let url = format!("https://{MAVEN_HOST}/{path}");
let sha1 = match artifact.sha1 {
Some(hash) => hash,
None => String::from_utf8(get(client, &format!("{url}.sha1"), 128)?)
.map_err(|e| e.to_string())?
.trim()
.to_owned(),
};
let size = match artifact.size {
Some(size) => size,
None => client
.head(&url)
.send()
.map_err(|e| e.to_string())?
.error_for_status()
.map_err(|e| e.to_string())?
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|h| h.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.ok_or("Fabric library has no size")?,
};
if sha1.len() != 40
|| !sha1.bytes().all(|b| b.is_ascii_hexdigit())
|| size == 0
|| size > 64 * 1024 * 1024
{
return Err("Invalid Fabric library hash or size".into());
}
library.downloads = Some(LibraryDownloads {
artifact: Some(Artifact {
path,
url,
sha1,
size,
}),
});
}
Ok(version)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maven_coordinates_cannot_escape_library_directory() {
assert_eq!(
coordinate_path("net.fabricmc:fabric-loader:0.19.5").unwrap(),
"net/fabricmc/fabric-loader/0.19.5/fabric-loader-0.19.5.jar"
);
for bad in [
"x:y:../evil",
"a..b:c:1",
"x:/tmp:1",
"x:y:1:extra",
"x:y:\\evil",
"x:y:..",
"CON:y:1",
"a:y.:1",
"a:AUX:1",
] {
assert!(coordinate_path(bad).is_err(), "{bad}");
}
}
#[test]
fn loader_identity_and_maven_origin_are_checked_before_downloads() {
let client = Client::new();
let base = serde_json::json!({"id":"fabric-loader-0.19.5-26.2", "inheritsFrom":"26.2", "mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient", "libraries":[{"name":"net.fabricmc:fabric-loader:0.19.5", "url":"https://maven.fabricmc.net/", "sha1":"a".repeat(40), "size":42}]});
assert!(normalize(
&client,
&serde_json::to_vec(&base).unwrap(),
"26.2",
"0.19.5"
)
.is_ok());
for (field, value) in [
("inheritsFrom", "1.21.1"),
("mainClass", "attacker.Main"),
("id", "wrong"),
] {
let mut bad = base.clone();
bad[field] = value.into();
assert!(normalize(
&client,
&serde_json::to_vec(&bad).unwrap(),
"26.2",
"0.19.5"
)
.is_err());
}
let mut bad = base;
bad["libraries"][0]["url"] = "https://attacker.invalid/".into();
assert!(normalize(
&client,
&serde_json::to_vec(&bad).unwrap(),
"26.2",
"0.19.5"
)
.is_err());
}
#[test]
#[ignore = "downloads official Fabric profile metadata"]
fn live_resolves_fabric_26_2() {
let profile = fetch_profile("26.2", "0.19.5").unwrap();
assert!(profile
.libraries
.iter()
.all(|library| library.downloads.is_some()));
}
}
+57 -50
View File
@@ -245,6 +245,10 @@ fn build_command(request: &LaunchRequest) -> Result<Command, LaunchError> {
for argument in game_args {
command.arg(substitute(&argument, &vars));
}
// This endpoint is native-owned; a manifest cannot redirect game admission.
if request.admission.server_id() == "minigames" {
command.args(["--quickPlayMultiplayer", "135.106.154.86:25568"]);
}
command.current_dir(request.profile_dir);
let log_file = fs::File::create(request.log_path)?;
@@ -268,59 +272,62 @@ mod tests {
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));
for server_id in ["aoc", "minigames"] {
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": server_id, "expires_in_seconds": 600
}))
.unwrap();
let admission = AdmissionKey::generate(server_id).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"]));
assert_eq!(arguments.windows(2).any(|pair| pair == ["--quickPlayMultiplayer", "135.106.154.86:25568"]), server_id == "minigames");
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);
}
drop(command);
fs::remove_dir_all(directory).unwrap();
}
+1
View File
@@ -2,6 +2,7 @@ mod admission;
#[cfg(target_os = "linux")]
mod deb_updater;
mod download;
mod fabric;
mod java;
mod launch;
mod manifest;
+1 -1
View File
@@ -91,7 +91,7 @@ fn validate(manifest: &Manifest) -> Result<(), ManifestError> {
));
}
if !is_version(&manifest.minecraft.version)
|| manifest.minecraft.loader.kind.trim().is_empty()
|| !matches!(manifest.minecraft.loader.kind.as_str(), "neoforge" | "fabric" | "vanilla")
|| !is_version(&manifest.minecraft.loader.version)
{
return Err(ManifestError::Invalid(
+2 -1
View File
@@ -58,6 +58,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
MOJANG_HOSTS[2],
MOJANG_HOSTS[3],
crate::neoforge::NEOFORGE_HOST,
crate::fabric::MAVEN_HOST,
],
std::time::Duration::from_secs(10 * 60),
)
@@ -67,7 +68,7 @@ pub fn library_http_client() -> Result<Client, reqwest::Error> {
/// own fixed Maven. The profile itself comes from the SHA-256-verified
/// NeoForge installer, never from the ShaCraft manifest.
fn is_allowed_library_host(url: &str) -> bool {
is_allowed_host(url) || crate::neoforge::is_allowed_host(url)
is_allowed_host(url) || crate::neoforge::is_allowed_host(url) || crate::trusted_http::allows(url, &[crate::fabric::MAVEN_HOST])
}
#[derive(Debug)]
+4
View File
@@ -13,6 +13,8 @@ use std::{
const AERONAUTICS_MANIFEST: &str =
"https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest";
const MINIGAMES_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/minigames/signed-manifest";
const MINIGAMES_ONLINE: &str = "https://shacraft.ru/api/online/minigames";
const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc";
const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ=";
const MANIFEST_KEY_ID: &str = "2026-09-06";
@@ -71,6 +73,7 @@ impl fmt::Display for RemoteError {
pub fn fetch_manifest(profile_id: &str) -> Result<Manifest, RemoteError> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_MANIFEST,
"minigames" => MINIGAMES_MANIFEST,
_ => return Err(RemoteError::UnknownProfile),
};
let client = Client::builder()
@@ -125,6 +128,7 @@ fn fetch_manifest_bytes(client: &Client, url: &str) -> Result<Vec<u8>, RemoteErr
pub fn fetch_server_status(profile_id: &str) -> Result<ServerStatus, RemoteError> {
let url = match profile_id {
"aeronautics" => AERONAUTICS_ONLINE,
"minigames" => MINIGAMES_ONLINE,
_ => return Err(RemoteError::UnknownProfile),
};
let client = Client::builder()
+3 -2
View File
@@ -274,9 +274,10 @@ pub fn claim_nickname(data_dir: &Path, nickname: &str) -> Result<Account, Accoun
/// this response is exposed to the webview or persisted with account settings.
pub(crate) fn issue_admission(
data_dir: &Path,
server_id: &'static str,
) -> Result<crate::admission::Admission, AccountError> {
let token = load_session(data_dir)?;
let key = crate::admission::AdmissionKey::generate()
let key = crate::admission::AdmissionKey::generate(server_id)
.map_err(|message| AccountError::Api(message.into()))?;
let response = client()?
.post(format!("{API_ORIGIN}{ADMISSION_ENDPOINT}"))
@@ -359,7 +360,7 @@ mod tests {
let directory = temporary_directory();
crate::settings::save(&directory, crate::settings::LauncherSettings::default()).unwrap();
assert!(matches!(
issue_admission(&directory),
issue_admission(&directory, "aoc"),
Err(AccountError::InvalidSession)
));
fs::remove_dir_all(directory).unwrap();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "ShaCraft Launcher",
"version": "0.1.5",
"version": "0.1.6",
"identifier": "ru.shacraft.launcher",
"build": {
"beforeDevCommand": "npm run dev",
+1 -1
View File
@@ -43,7 +43,7 @@ export function AccountSettings({ session, locked }: { session: ReturnType<typeo
{account && !linkedNickname && (
<form noValidate onSubmit={(event) => { event.preventDefault(); if (!disabled) void session.startLink(nickname) }}>
<label className="text-setting">
<span><strong>Игровой ник</strong><small>Aeronautics</small></span>
<span><strong>Игровой ник</strong><small>Общий ник ShaCraft</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>Свободный ник закрепляется за аккаунтом. Для старого игрового ника обратитесь к администратору.</small>
+2 -2
View File
@@ -24,7 +24,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
<button className="rail-button active" aria-label="Настройки" onClick={onSettings}><Settings /></button>
</nav>
<aside className="library-panel">
<div className="library-heading"><p>Сборки</p><span>{servers.length} доступна</span></div>
<div className="library-heading"><p>Сборки</p><span>{servers.length} сборки</span></div>
<div className="server-list">
{servers.map((server) => {
const profile = profiles[server.profileId]
@@ -33,7 +33,7 @@ export function Library({ selected, profiles, account, locked, native, onSelect,
return (
<button key={server.id} className={`server-row ${selected.id === server.id ? 'selected' : ''}`}
aria-pressed={selected.id === server.id} disabled={locked} onClick={() => onSelect(server)}>
<span className={`server-glyph ${server.id}`} aria-hidden="true">A</span>
<span className={`server-glyph ${server.id}`} aria-hidden="true">{server.name.slice(0, 1)}</span>
<span className="server-copy"><strong>{server.name}</strong><small>{status}</small></span>
<ChevronRight size={16} />
</button>
+1 -1
View File
@@ -17,7 +17,7 @@ export function ServerStage({ server, status, children }: { server: Server; stat
<p>{server.kicker}</p><h1>{server.name}</h1><h2>{server.subtitle}</h2>
<dl className="hero-meta">
<div><dt>Загрузчик</dt><dd>{server.loader}</dd></div>
<div><dt>Java</dt><dd>Версия 21</dd></div>
<div><dt>Java</dt><dd>Версия {server.id === 'minigames' ? 25 : 21}</dd></div>
</dl>
</section>
{children}
+16 -1
View File
@@ -3,6 +3,9 @@ import { test } from 'node:test'
import { renderToStaticMarkup } from 'react-dom/server'
import { AccountSettings } from './AccountSettings'
import { RecoveryCodesModal } from './RecoveryCodesModal'
import { ServerStage } from './ServerStage'
import { Library } from './Library'
import { servers } from '../data/servers'
import type { useAccount } from '../hooks/useAccount'
function session(): ReturnType<typeof useAccount> {
@@ -31,7 +34,7 @@ test('linked identity is displayed read-only while an unlinked account offers ve
doesNotMatch(linked, /<input|Привязать ник/)
const unlinked = renderToStaticMarkup(<AccountSettings session={{ ...session(), account: { username: 'website_login', links: [] } }} locked={false} />)
match(unlinked, /Привязать ник/)
match(unlinked, /Aeronautics/)
match(unlinked, /Общий ник ShaCraft/)
})
test('recovery codes render only until explicitly acknowledged', () => {
@@ -43,3 +46,15 @@ test('recovery codes render only until explicitly acknowledged', () => {
match(html, /Я сохранил коды/)
equal(renderToStaticMarkup(<RecoveryCodesModal codes={[]} onAcknowledge={() => {}} />), '')
})
test('both profiles remain selectable and display their own runtime requirements', () => {
const library = renderToStaticMarkup(<Library selected={servers[1]!} profiles={{}} account={null}
locked={false} native={false} onSelect={() => {}} onSettings={() => {}} />)
match(library, /Aeronautics/)
match(library, /Minigames/)
equal((library.match(/class="server-row/g) ?? []).length, 2)
const stage = (index: number) => renderToStaticMarkup(<ServerStage server={servers[index]!} status={null}>{null}</ServerStage>)
match(stage(0), /Версия 21/)
match(stage(1), /Версия 25/)
match(stage(1), /Fabric 0.19.5/)
})
+5
View File
@@ -12,4 +12,9 @@ export const servers: readonly [Server, ...Server[]] = [
loader: 'NeoForge 21.1.248',
profileId: 'aeronautics',
},
{
id: 'minigames', kicker: 'Лобби и арены', name: 'Minigames',
subtitle: 'Небесные острова. Сражения на аренах SMASH.',
version: '26.2 · Fabric 0.19.5', loader: 'Fabric 0.19.5', profileId: 'minigames',
},
]
+1 -1
View File
@@ -1,7 +1,7 @@
import type { ShaCraftAccount } from '../types/launcher'
import { isValidNickname } from './settings'
/** Only the authenticated account's verified Aeronautics link selects a name. */
/** Only the authenticated account's verified aoc link selects the shared network nickname. */
export function linkedNickname(account: ShaCraftAccount | null | undefined): string | null {
const nickname = account?.links.find((link) => link.server_id === 'aoc')?.mc_username
return nickname && isValidNickname(nickname) ? nickname : null