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
15 changes: 15 additions & 0 deletions .github/workflows/beta-handoff.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Beta launcher handoff
on:
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: windows-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: dotnet build src/Flarial.Runtime/Flarial.Runtime.csproj
- run: dotnet run --project tests/BetaTokenHandoff/BetaTokenHandoff.csproj
12 changes: 9 additions & 3 deletions src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,15 @@ public static bool IsRunning
}
}

public bool Launch()
private protected virtual Task<string?> PrepareLaunchAsync() => Task.FromResult<string?>(null);

public bool Launch() => Task.Run(LaunchAsync).GetAwaiter().GetResult();

public async Task<bool> LaunchAsync()
{
if (!IsRunning && InjectionSession.Launch(new(FileName)))
if (IsRunning) return false;
var accessToken = await PrepareLaunchAsync().ConfigureAwait(false);
if (InjectionSession.Launch(new(FileName), accessToken))
{
_ = PostAnalyticsAsync();
return true;
Expand All @@ -61,7 +67,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
99 changes: 75 additions & 24 deletions src/Flarial.Runtime/Core/FlarialClientBeta.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
using System;
using System.Buffers.Text;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using Flarial.Runtime.Identity.Flarial;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Net.Http;
Expand Down Expand Up @@ -26,37 +32,82 @@ internal string? AccessToken
get => Interlocked.CompareExchange(ref field, null, null);
}

private protected override async Task<bool> VerifyClientAsync()
// Personalized builds are single-use activation material. Fetch again for a new launch.
private protected override Task<bool> VerifyClientAsync() => Task.FromResult(false);

string? _downloadAccount;

static string AccountId(string accessToken)
{
/*
- Inspect the client's commit hash via an exported symbol.
- Somewhat "efficient" over an actual hash when updating.
*/
var parts = accessToken.Split('.');
if (parts.Length != 3) throw new InvalidOperationException("Expected a Flarial access token.");
using var document = JsonDocument.Parse(Base64Url.DecodeFromChars(parts[1]));
return document.RootElement.GetProperty("sub").GetString()
?? throw new InvalidOperationException("Missing account ID.");
}

var hash = $"_{await GetRemoteHashAsync()}_"; unsafe
{
fixed (byte* ptr = Encoding.UTF8.GetBytes(hash))
{
var path = Path.GetFullPath(FileName);
private protected override async Task<string?> PrepareLaunchAsync()
{
var token = await AuthenticationManager.AuthenticateSilentlyAsync().ConfigureAwait(false);
if (token is null || _downloadAccount is null || AccountId(token) != _downloadAccount)
throw new InvalidOperationException("Sign in and download beta with the same account before launching.");
_downloadAccount = null; // Do not attempt to reuse consumed activation material.
return token;
}

if (DONT_RESOLVE_DLL_REFERENCES.Open(path) is not { } module)
return false;
public override Task<bool> DownloadAsync<T>(T progress) => DownloadClientAsync(progress);

using (module)
return !GetProcAddress(module, new(ptr)).IsNull;
private protected override async Task<bool> DownloadClientAsync<T>(T progress)
{
_downloadAccount = null;
var token = await AuthenticationManager.AuthenticateSilentlyAsync().ConfigureAwait(false);
if (token is null) return false;
var accountId = AccountId(token);
var temp = FileName + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
using HttpRequestMessage request = new(HttpMethod.Post, DownloadUri);
request.Headers.Authorization = new("Bearer", token);
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead).ConfigureAwait(false);
if (response.StatusCode != System.Net.HttpStatusCode.OK) return false;
var commit = response.Headers.GetValues("X-Flarial-Beta-Commit").Single();
var expectedHash = response.Headers.GetValues("X-Flarial-Dll-Sha256").Single();
if (commit.Length != 40 || commit.Any(c => !char.IsAsciiHexDigit(c))) return false;
const int maximum = 32 * 1024 * 1024;
if (response.Content.Headers.ContentLength > maximum) return false;
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(2));
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
using (var destination = File.Create(temp))
using (var source = await response.Content.ReadAsStreamAsync(cancellation.Token).ConfigureAwait(false))
{
byte[] buffer = new byte[64 * 1024];
int total = 0, count;
while ((count = await source.ReadAsync(buffer, cancellation.Token).ConfigureAwait(false)) != 0)
{
total += count;
if (total > maximum) throw new IOException("Beta DLL exceeds 32 MiB.");
hash.AppendData(buffer, 0, count);
await destination.WriteAsync(buffer.AsMemory(0, count), cancellation.Token).ConfigureAwait(false);
if (response.Content.Headers.ContentLength is > 0 and var size)
progress.Report((int)(100L * total / size));
}
}
if (!Convert.ToHexString(hash.GetHashAndReset()).Equals(expectedHash, StringComparison.OrdinalIgnoreCase))
throw new IOException("Beta DLL SHA-256 mismatch.");
if (!HasCommitExport(temp, commit)) throw new IOException("Beta DLL commit export mismatch.");
File.Move(temp, FileName, true);
_downloadAccount = accountId;
return true;
}
finally { if (File.Exists(temp)) File.Delete(temp); }
}

private protected override async Task<bool> DownloadClientAsync<T>(T progress)
static unsafe bool HasCommitExport(string path, string commit)
{
using HttpRequestMessage request = new(HttpMethod.Post, DownloadUri);
request.Headers.Authorization = new("Bearer", AccessToken);

using var response = await HttpService.SendAsync(request);
if (!response.IsSuccessStatusCode) return false;

await response.DownloadAsync(FileName, progress);
return true;
using var module = DONT_RESOLVE_DLL_REFERENCES.Open(Path.GetFullPath(path));
if (module is null) return false;
fixed (byte* ptr = Encoding.UTF8.GetBytes("_" + commit + "_\0"))
return !GetProcAddress(module.Value, new(ptr)).IsNull;
}
}
}
69 changes: 69 additions & 0 deletions src/Flarial.Runtime/Game/BetaTokenHandoff.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System;
using System.Buffers.Binary;
using System.IO;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;

namespace Flarial.Runtime.Game;

// One short-lived access token, delivered only to the target process under the same user.
sealed class BetaTokenHandoff : IDisposable
{
readonly NamedPipeServerStream _pipe;
readonly CancellationTokenSource _timeout = new(TimeSpan.FromSeconds(30));
readonly Task _delivery;

[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool GetNamedPipeClientProcessId(SafePipeHandle pipe, out uint processId);

internal BetaTokenHandoff(uint processId, string accessToken)
{
if (string.IsNullOrEmpty(accessToken) || accessToken.Length > 4096)
throw new ArgumentException("Invalid beta access token.", nameof(accessToken));
_pipe = new NamedPipeServerStream($"Flarial.Beta.Auth.{processId}", PipeDirection.Out, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
_delivery = DeliverAsync(processId, accessToken);
}

async Task DeliverAsync(uint processId, string accessToken)
{
byte[] bytes = Encoding.UTF8.GetBytes(accessToken);
try
{
while (true)
{
await _pipe.WaitForConnectionAsync(_timeout.Token).ConfigureAwait(false);
if (GetNamedPipeClientProcessId(_pipe.SafePipeHandle, out var client) && client == processId)
break;
_pipe.Disconnect();
}
byte[] prefix = new byte[4];
BinaryPrimitives.WriteInt32BigEndian(prefix, bytes.Length);
await _pipe.WriteAsync(prefix, _timeout.Token).ConfigureAwait(false);
await _pipe.WriteAsync(bytes, _timeout.Token).ConfigureAwait(false);
await _pipe.FlushAsync(_timeout.Token).ConfigureAwait(false);
}
finally { Array.Clear(bytes); }
}

internal bool WaitForDelivery()
{
try { _delivery.GetAwaiter().GetResult(); return true; }
catch (IOException) { return false; }
catch (OperationCanceledException) { return false; }
}

public void Dispose()
{
_timeout.Cancel();
_pipe.Dispose();
try { _delivery.GetAwaiter().GetResult(); } catch (IOException) { }
catch (OperationCanceledException) { } catch (ObjectDisposedException) { }
_timeout.Dispose();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,15 @@ 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);
using var handoff = betaAccessToken is null ? null : new BetaTokenHandoff(processId, betaAccessToken);
return session.Inject(processId) && (handoff?.WaitForDelivery() ?? true);
}
}
11 changes: 11 additions & 0 deletions tests/BetaTokenHandoff/BetaTokenHandoff.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<Compile Include="../../src/Flarial.Runtime/Game/BetaTokenHandoff.cs" Link="BetaTokenHandoff.cs" />
</ItemGroup>
</Project>
52 changes: 52 additions & 0 deletions tests/BetaTokenHandoff/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;
using System.Buffers.Binary;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Reflection;
using System.Threading.Tasks;
using Flarial.Runtime.Game;

const string token = "fake.header.signature";
if (args.Length != 0)
{
var pipeName = Console.ReadLine()!;
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.In, PipeOptions.Asynchronous);
await client.ConnectAsync(5000);
byte[] prefix = new byte[4];
if (args[0] == "reject")
{
try { return await client.ReadAsync(prefix) == 0 ? 0 : 1; }
catch (IOException) { return 0; }
}
await client.ReadExactlyAsync(prefix);
var length = BinaryPrimitives.ReadInt32BigEndian(prefix);
if (length != token.Length) return 1;
byte[] bytes = new byte[length];
await client.ReadExactlyAsync(bytes);
return System.Text.Encoding.UTF8.GetString(bytes) == token ? 0 : 1;
}
if (!OperatingSystem.IsWindows()) throw new PlatformNotSupportedException("Run on Windows.");

foreach (bool wrongProcess in new[] { false, true })
{
var info = new ProcessStartInfo("dotnet") { RedirectStandardInput = true, UseShellExecute = false };
info.ArgumentList.Add(Assembly.GetExecutingAssembly().Location);
info.ArgumentList.Add(wrongProcess ? "reject" : "receive");
using var child = Process.Start(info)!;
uint target = (uint)(wrongProcess ? Environment.ProcessId : child.Id);
using var handoff = new BetaTokenHandoff(target, token);
await child.StandardInput.WriteLineAsync($"Flarial.Beta.Auth.{target}");
await child.StandardInput.FlushAsync();
await child.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
if (child.ExitCode != 0) throw new Exception("Unexpected client result.");
if (!wrongProcess && !handoff.WaitForDelivery()) throw new Exception("Delivery failed.");
}
foreach (var invalid in new[] { "", new string('x', 4097) })
{
try { using var handoff = new BetaTokenHandoff((uint)Environment.ProcessId, invalid); }
catch (ArgumentException) { continue; }
throw new Exception("Invalid token accepted.");
}
Console.WriteLine("PASS: target process receives token; other process rejected; cancellation and length limits.");
return 0;
Loading