Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/beta-account-handoff.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 4 additions & 2 deletions src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -61,7 +63,7 @@ private protected async Task<string> GetRemoteHashAsync()
return json[HashName];
}

public async Task<bool> DownloadAsync<T>(T progress) where T : IProgress<int>
public virtual async Task<bool> DownloadAsync<T>(T progress) where T : IProgress<int>
{
if (await VerifyClientAsync())
return true;
Expand Down
14 changes: 14 additions & 0 deletions src/Flarial.Runtime/Core/FlarialClientBeta.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +27,19 @@ internal string? AccessToken
get => Interlocked.CompareExchange(ref field, null, null);
}

public override async Task<bool> DownloadAsync<T>(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<bool> VerifyClientAsync()
{
/*
Expand Down
88 changes: 88 additions & 0 deletions src/Flarial.Runtime/Game/BetaTokenHandoff.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ 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;

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);
}
}
9 changes: 9 additions & 0 deletions src/Flarial.Runtime/Identity/Flarial/AccountManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ public static async Task<bool> AuthenticateAsync()
finally { s_semaphore.Release(); }
}

internal static async Task<string?> RefreshAccessTokenAsync()
{
await s_semaphore.WaitAsync(); try
{
return await AuthenticationManager.AuthenticateSilentlyAsync();
}
finally { s_semaphore.Release(); }
}

public static async Task<AccountDetails?> LoginAsync()
{
await s_semaphore.WaitAsync(); try
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using Flarial.Runtime.Services;

namespace Flarial.Runtime.Identity.Flarial;

sealed class AccountRefreshTokenManager : CredentialService<AccountRefreshTokenManager>
{
private protected override string Username => "Flarial Account";
private protected override string Resource => "Flarial Launcher";
}
12 changes: 6 additions & 6 deletions src/Flarial.Runtime/Identity/Flarial/AuthenticationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,17 +106,17 @@ internal static async Task<bool> AuthenticateAsync()
{
if (await GetTokensAsync() is { } token)
{
RefreshTokenManager._.Set(token.RefreshToken);
AccountRefreshTokenManager._.Set(token.RefreshToken);
return true;
}
return false;
}

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<string, string>
Expand All @@ -132,7 +132,7 @@ internal static async Task RevokeAsync()

internal static async Task<string?> AuthenticateSilentlyAsync()
{
if (RefreshTokenManager._.Get() is not { } refreshToken)
if (AccountRefreshTokenManager._.Get() is not { } refreshToken)
return null;

using FormUrlEncodedContent content = new(new Dictionary<string, string>
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/Flarial.Runtime/NativeMethods.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,6 @@ PROCESS_CREATION_FLAGS
PACKAGE_FAMILY_NAME_MAX_LENGTH
WTS_CURRENT_SESSION
WTS_CURRENT_SERVER_HANDLE
WTS_PROCESS_INFOW
WTS_PROCESS_INFOW
EnumProcessModulesEx
GetModuleFileNameEx
12 changes: 12 additions & 0 deletions tests/BetaTokenHandoff/BetaTokenHandoff.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../src/Flarial.Runtime/Flarial.Runtime.csproj" />
</ItemGroup>
</Project>
11 changes: 11 additions & 0 deletions tests/BetaTokenHandoff/Fixture.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#include <Windows.h>

struct TokenHandoff {
LONG ready;
ULONG length;
char token[4096];
};

extern "C" {
__declspec(dllexport) TokenHandoff FlarialBetaAccessTokenHandoff{};
}
44 changes: 44 additions & 0 deletions tests/BetaTokenHandoff/Program.cs
Original file line number Diff line number Diff line change
@@ -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<byte>(handoff + 8, token.Length)) == token);
new Span<byte>(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); }
}
}
Loading