From ab55698960f8b039553e426d84156d6de6906efc Mon Sep 17 00:00:00 2001 From: root Date: Sat, 3 Oct 2026 05:31:00 +0000 Subject: [PATCH] Add beta access-token handoff and verified personalized downloads --- .github/workflows/beta-handoff.yml | 15 +++ .../Core/FlarialClient/FlarialClient.Base.cs | 12 ++- src/Flarial.Runtime/Core/FlarialClientBeta.cs | 99 ++++++++++++++----- src/Flarial.Runtime/Game/BetaTokenHandoff.cs | 69 +++++++++++++ .../InjectionSession/InjectionSession.Base.cs | 5 +- .../BetaTokenHandoff/BetaTokenHandoff.csproj | 11 +++ tests/BetaTokenHandoff/Program.cs | 52 ++++++++++ 7 files changed, 234 insertions(+), 29 deletions(-) create mode 100644 .github/workflows/beta-handoff.yml create mode 100644 src/Flarial.Runtime/Game/BetaTokenHandoff.cs create mode 100644 tests/BetaTokenHandoff/BetaTokenHandoff.csproj create mode 100644 tests/BetaTokenHandoff/Program.cs diff --git a/.github/workflows/beta-handoff.yml b/.github/workflows/beta-handoff.yml new file mode 100644 index 0000000..0b86ac5 --- /dev/null +++ b/.github/workflows/beta-handoff.yml @@ -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 diff --git a/src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs b/src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs index 53c83d0..1d50d90 100644 --- a/src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs +++ b/src/Flarial.Runtime/Core/FlarialClient/FlarialClient.Base.cs @@ -45,9 +45,15 @@ public static bool IsRunning } } - public bool Launch() + private protected virtual Task PrepareLaunchAsync() => Task.FromResult(null); + + public bool Launch() => Task.Run(LaunchAsync).GetAwaiter().GetResult(); + + public async Task 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; @@ -61,7 +67,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..602ece0 100644 --- a/src/Flarial.Runtime/Core/FlarialClientBeta.cs +++ b/src/Flarial.Runtime/Core/FlarialClientBeta.cs @@ -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; @@ -26,37 +32,82 @@ internal string? AccessToken get => Interlocked.CompareExchange(ref field, null, null); } - private protected override async Task VerifyClientAsync() + // Personalized builds are single-use activation material. Fetch again for a new launch. + private protected override Task 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 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 DownloadAsync(T progress) => DownloadClientAsync(progress); - using (module) - return !GetProcAddress(module, new(ptr)).IsNull; + private protected override async Task DownloadClientAsync(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 DownloadClientAsync(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; } -} \ No newline at end of file +} diff --git a/src/Flarial.Runtime/Game/BetaTokenHandoff.cs b/src/Flarial.Runtime/Game/BetaTokenHandoff.cs new file mode 100644 index 0000000..0532a3a --- /dev/null +++ b/src/Flarial.Runtime/Game/BetaTokenHandoff.cs @@ -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(); + } +} diff --git a/src/Flarial.Runtime/Game/InjectionSession/InjectionSession.Base.cs b/src/Flarial.Runtime/Game/InjectionSession/InjectionSession.Base.cs index 260e5f6..d029660 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,7 @@ internal static bool Launch(ModificationLibrary library) 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); } } \ No newline at end of file diff --git a/tests/BetaTokenHandoff/BetaTokenHandoff.csproj b/tests/BetaTokenHandoff/BetaTokenHandoff.csproj new file mode 100644 index 0000000..48bb5d6 --- /dev/null +++ b/tests/BetaTokenHandoff/BetaTokenHandoff.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + true + + + + + diff --git a/tests/BetaTokenHandoff/Program.cs b/tests/BetaTokenHandoff/Program.cs new file mode 100644 index 0000000..c4cd20b --- /dev/null +++ b/tests/BetaTokenHandoff/Program.cs @@ -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;