diff --git a/.github/workflows/beta-account-handoff.yml b/.github/workflows/beta-account-handoff.yml new file mode 100644 index 0000000..73ba29a --- /dev/null +++ b/.github/workflows/beta-account-handoff.yml @@ -0,0 +1,26 @@ +name: Beta account handoff tests + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + handoff: + runs-on: windows-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '10.0.x' + - uses: ilammy/msvc-dev-cmd@v1 + - name: Build native handoff fixture + shell: pwsh + run: | + cl /nologo /LD /W4 /WX tests/BetaTokenHandoff/Fixture.cpp /link /OUT:BetaHandoffFixture.dll + if ($LASTEXITCODE -ne 0) { throw 'Handoff fixture compilation failed' } + - name: Test writes to the loaded target module + run: dotnet run --project tests/BetaTokenHandoff/BetaTokenHandoff.Tests.csproj --configuration Release -- BetaHandoffFixture.dll diff --git a/src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs b/src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs index 53c83d0..c2e4309 100644 --- a/src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs +++ b/src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs @@ -45,9 +45,11 @@ public static bool IsRunning } } + private protected virtual bool InjectClient() => InjectionSession.Launch(new(FileName)); + public bool Launch() { - if (!IsRunning && InjectionSession.Launch(new(FileName))) + if (!IsRunning && InjectClient()) { _ = PostAnalyticsAsync(); return true; @@ -61,7 +63,7 @@ private protected async Task GetRemoteHashAsync() return json[HashName]; } - public async Task DownloadAsync(T progress) where T : IProgress + public virtual async Task DownloadAsync(T progress) where T : IProgress { if (await VerifyClientAsync()) return true; diff --git a/src/Flarial.Runtime/Core/FlarialClientBeta.cs b/src/Flarial.Runtime/Core/FlarialClientBeta.cs index 1ed321f..40abfe9 100644 --- a/src/Flarial.Runtime/Core/FlarialClientBeta.cs +++ b/src/Flarial.Runtime/Core/FlarialClientBeta.cs @@ -5,6 +5,7 @@ using System.Threading; using System.Threading.Tasks; using Flarial.Runtime.Services; +using Flarial.Runtime.Game; using Flarial.Runtime.Unmanaged; using static Windows.Win32.PInvoke; using static Windows.Win32.System.LibraryLoader.LOAD_LIBRARY_FLAGS; @@ -26,6 +27,19 @@ internal string? AccessToken get => Interlocked.CompareExchange(ref field, null, null); } + public override async Task DownloadAsync(T progress) + { + if (await Identity.Flarial.AccountManager.RefreshAccessTokenAsync() is null) + return false; + return await base.DownloadAsync(progress); + } + + private protected override bool InjectClient() + { + var token = AccessToken; + return !string.IsNullOrEmpty(token) && InjectionSession.Launch(new(FileName), token); + } + private protected override async Task VerifyClientAsync() { /* diff --git a/src/Flarial.Runtime/Game/BetaTokenHandoff.cs b/src/Flarial.Runtime/Game/BetaTokenHandoff.cs new file mode 100644 index 0000000..bba3977 --- /dev/null +++ b/src/Flarial.Runtime/Game/BetaTokenHandoff.cs @@ -0,0 +1,88 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using Flarial.Runtime.Unmanaged; +using Windows.Win32.Foundation; +using static Windows.Win32.PInvoke; +using static Windows.Win32.System.LibraryLoader.LOAD_LIBRARY_FLAGS; +using static Windows.Win32.System.ProcessStatus.ENUM_PROCESS_MODULES_EX_FLAGS; +using static Windows.Win32.System.Threading.PROCESS_ACCESS_RIGHTS; + +namespace Flarial.Runtime.Game; + +static class BetaTokenHandoff +{ + internal static unsafe bool Deliver(uint processId, string path, string accessToken) + { + if (string.IsNullOrEmpty(accessToken) || accessToken.IndexOfAny(['\r', '\n', '\0']) >= 0) + return false; + + var token = Encoding.UTF8.GetBytes(accessToken); + var payload = new byte[4100]; + try + { + if (token.Length > 4096) return false; + BinaryPrimitives.WriteUInt32LittleEndian(payload, (uint)token.Length); + token.CopyTo(payload, 4); + + path = Path.GetFullPath(path); + if (DONT_RESOLVE_DLL_REFERENCES.Open(path) is not { } module) return false; + using (module) + { + fixed (byte* name = "FlarialBetaAccessTokenHandoff"u8) + { + var export = GetProcAddress(module, new(name)); + if (export.IsNull) return false; + var offset = (nint)export - (nint)((HMODULE)module).Value; + + if ((PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | PROCESS_VM_WRITE | + PROCESS_VM_OPERATION).Open(processId) is not { } process) return false; + using (process) + { + var modules = new HMODULE[4096]; + uint needed; + fixed (HMODULE* handles = modules) + { + if (!EnumProcessModulesEx(process, handles, + (uint)(modules.Length * sizeof(HMODULE)), &needed, LIST_MODULES_64BIT) || + needed > modules.Length * sizeof(HMODULE)) return false; + } + + var filename = new char[32768]; + for (var index = 0; index < needed / sizeof(HMODULE); ++index) + { + fixed (char* filenamePtr = filename) + { + var length = GetModuleFileNameEx(process, modules[index], + filenamePtr, (uint)filename.Length); + if (length == 0 || length >= filename.Length || + !string.Equals(new string(filenamePtr, 0, (int)length), path, + StringComparison.OrdinalIgnoreCase)) continue; + } + + var target = (byte*)modules[index].Value + offset; + nuint written; + fixed (byte* payloadPtr = payload) + { + if (!WriteProcessMemory(process, target + 4, payloadPtr, + (nuint)payload.Length, &written) || + written != (nuint)payload.Length) return false; + } + uint ready = 1; + return WriteProcessMemory(process, target, &ready, sizeof(uint), &written) && + written == sizeof(uint); + } + } + } + } + return false; + } + finally + { + CryptographicOperations.ZeroMemory(token); + CryptographicOperations.ZeroMemory(payload); + } + } +} diff --git a/src/Flarial.Runtime/Game/InjectionSession/InjectionSession.Base.cs b/src/Flarial.Runtime/Game/InjectionSession/InjectionSession.Base.cs index 260e5f6..c286aa2 100644 --- a/src/Flarial.Runtime/Game/InjectionSession/InjectionSession.Base.cs +++ b/src/Flarial.Runtime/Game/InjectionSession/InjectionSession.Base.cs @@ -20,7 +20,7 @@ unsafe static InjectionSession() } } - internal static bool Launch(ModificationLibrary library) + internal static bool Launch(ModificationLibrary library, string? betaAccessToken = null) { if (Create(library) is not { } session) return false; @@ -28,6 +28,8 @@ internal static bool Launch(ModificationLibrary library) if (Minecraft.Launch() is not { } processId) return false; - return session.Inject(processId); + if (!session.Inject(processId)) return false; + return betaAccessToken is null || + BetaTokenHandoff.Deliver(processId, session._paths[session._paths.Count - 1], betaAccessToken); } } \ No newline at end of file diff --git a/src/Flarial.Runtime/Identity/Flarial/AccountManager.cs b/src/Flarial.Runtime/Identity/Flarial/AccountManager.cs index 6fdff60..0eb9617 100644 --- a/src/Flarial.Runtime/Identity/Flarial/AccountManager.cs +++ b/src/Flarial.Runtime/Identity/Flarial/AccountManager.cs @@ -24,6 +24,15 @@ public static async Task AuthenticateAsync() finally { s_semaphore.Release(); } } + internal static async Task RefreshAccessTokenAsync() + { + await s_semaphore.WaitAsync(); try + { + return await AuthenticationManager.AuthenticateSilentlyAsync(); + } + finally { s_semaphore.Release(); } + } + public static async Task LoginAsync() { await s_semaphore.WaitAsync(); try diff --git a/src/Flarial.Runtime/Identity/Flarial/AccountRefreshTokenManager.cs b/src/Flarial.Runtime/Identity/Flarial/AccountRefreshTokenManager.cs new file mode 100644 index 0000000..93a5571 --- /dev/null +++ b/src/Flarial.Runtime/Identity/Flarial/AccountRefreshTokenManager.cs @@ -0,0 +1,9 @@ +using Flarial.Runtime.Services; + +namespace Flarial.Runtime.Identity.Flarial; + +sealed class AccountRefreshTokenManager : CredentialService +{ + private protected override string Username => "Flarial Account"; + private protected override string Resource => "Flarial Launcher"; +} diff --git a/src/Flarial.Runtime/Identity/Flarial/AuthenticationManager.cs b/src/Flarial.Runtime/Identity/Flarial/AuthenticationManager.cs index b93f83b..5dfc412 100644 --- a/src/Flarial.Runtime/Identity/Flarial/AuthenticationManager.cs +++ b/src/Flarial.Runtime/Identity/Flarial/AuthenticationManager.cs @@ -106,7 +106,7 @@ internal static async Task AuthenticateAsync() { if (await GetTokensAsync() is { } token) { - RefreshTokenManager._.Set(token.RefreshToken); + AccountRefreshTokenManager._.Set(token.RefreshToken); return true; } return false; @@ -114,9 +114,9 @@ internal static async Task AuthenticateAsync() internal static async Task RevokeAsync() { - if (RefreshTokenManager._.Get() is { } refreshToken) + if (AccountRefreshTokenManager._.Get() is { } refreshToken) { - RefreshTokenManager._.Remove(); + AccountRefreshTokenManager._.Remove(); FlarialClientBeta._.AccessToken = null; using FormUrlEncodedContent content = new(new Dictionary @@ -132,7 +132,7 @@ internal static async Task RevokeAsync() internal static async Task AuthenticateSilentlyAsync() { - if (RefreshTokenManager._.Get() is not { } refreshToken) + if (AccountRefreshTokenManager._.Get() is not { } refreshToken) return null; using FormUrlEncodedContent content = new(new Dictionary @@ -147,14 +147,14 @@ internal static async Task RevokeAsync() if (!response.IsSuccessStatusCode) { - await AccountManager.LogoutAsync(); + await RevokeAsync(); return null; } if (await ParseTokensAsync(response) is not { } tuple) return null; - RefreshTokenManager._.Set(tuple.RefreshToken); + AccountRefreshTokenManager._.Set(tuple.RefreshToken); FlarialClientBeta._.AccessToken = tuple.AccessToken; return tuple.AccessToken; diff --git a/src/Flarial.Runtime/NativeMethods.txt b/src/Flarial.Runtime/NativeMethods.txt index 12903ad..2aa0500 100644 --- a/src/Flarial.Runtime/NativeMethods.txt +++ b/src/Flarial.Runtime/NativeMethods.txt @@ -38,4 +38,6 @@ PROCESS_CREATION_FLAGS PACKAGE_FAMILY_NAME_MAX_LENGTH WTS_CURRENT_SESSION WTS_CURRENT_SERVER_HANDLE -WTS_PROCESS_INFOW \ No newline at end of file +WTS_PROCESS_INFOW +EnumProcessModulesEx +GetModuleFileNameEx diff --git a/tests/BetaTokenHandoff/BetaTokenHandoff.Tests.csproj b/tests/BetaTokenHandoff/BetaTokenHandoff.Tests.csproj new file mode 100644 index 0000000..e3f03df --- /dev/null +++ b/tests/BetaTokenHandoff/BetaTokenHandoff.Tests.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0-windows10.0.19041.0 + true + enable + true + + + + + diff --git a/tests/BetaTokenHandoff/Fixture.cpp b/tests/BetaTokenHandoff/Fixture.cpp new file mode 100644 index 0000000..b7f71bc --- /dev/null +++ b/tests/BetaTokenHandoff/Fixture.cpp @@ -0,0 +1,11 @@ +#include + +struct TokenHandoff { + LONG ready; + ULONG length; + char token[4096]; +}; + +extern "C" { +__declspec(dllexport) TokenHandoff FlarialBetaAccessTokenHandoff{}; +} diff --git a/tests/BetaTokenHandoff/Program.cs b/tests/BetaTokenHandoff/Program.cs new file mode 100644 index 0000000..63944df --- /dev/null +++ b/tests/BetaTokenHandoff/Program.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using Flarial.Runtime.Core; + +static class Program +{ + static void Check(bool condition) + { + if (!condition) throw new InvalidOperationException("Beta token handoff assertion failed"); + } + + static unsafe void Main(string[] args) + { + var path = Path.GetFullPath(args[0]); + var module = NativeLibrary.Load(path); + try + { + var method = typeof(FlarialClient).Assembly.GetType("Flarial.Runtime.Game.BetaTokenHandoff")! + .GetMethod("Deliver", BindingFlags.Static | BindingFlags.NonPublic)!; + bool Deliver(string token) => (bool)method.Invoke(null, [(uint)Environment.ProcessId, path, token])!; + var handoff = (byte*)NativeLibrary.GetExport(module, "FlarialBetaAccessTokenHandoff"); + foreach (var token in new[] { "synthetic-account-token", new string('x', 4096) }) + { + Check(Deliver(token)); + Check(*(uint*)handoff == 1); + Check(*(uint*)(handoff + 4) == token.Length); + Check(Encoding.UTF8.GetString(new ReadOnlySpan(handoff + 8, token.Length)) == token); + new Span(handoff, 4104).Clear(); + } + foreach (var token in new[] { "", "bad\r\nheader", "bad\0token", new string('x', 4097) }) + { + Check(!Deliver(token)); + Check(*(uint*)handoff == 0); + Check(*(uint*)(handoff + 4) == 0); + } + Check(!(bool)method.Invoke(null, [0u, path, "synthetic-token"])!); + Console.WriteLine("Beta token handoff tests passed"); + } + finally { NativeLibrary.Free(module); } + } +}