From dfb1e4020350f02d52b13ea3020761a52c8a1b40 Mon Sep 17 00:00:00 2001 From: Adam Scheurer Date: Wed, 15 Jul 2026 09:07:37 -0600 Subject: [PATCH] fix: store GitHub API token encrypted via SecretStore --- MainWindow.axaml.cs | 3 + Models/GameInfo.cs | 3 +- Services/AppSettings.cs | 7 +- Services/SecretStore.cs | 180 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 Services/SecretStore.cs diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 243e94d..2cc518e 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -1892,7 +1892,10 @@ private void UpdateSettingsUI() } if (GitHubTokenTextBox != null) + { GitHubTokenTextBox.Text = _settings.GitHubApiToken; + GitHubTokenTextBox.PasswordChar = '*'; + } if (GamePathTextBox != null) GamePathTextBox.Text = _settings.AppsPath; diff --git a/Models/GameInfo.cs b/Models/GameInfo.cs index 010b4b5..546c417 100644 --- a/Models/GameInfo.cs +++ b/Models/GameInfo.cs @@ -1243,8 +1243,7 @@ private string GetGitHubApiToken() { try { - var settings = AppSettings.Load(); - return settings?.GitHubApiToken ?? string.Empty; + return SecretStore.ReadToken(); } catch { diff --git a/Services/AppSettings.cs b/Services/AppSettings.cs index 7f2fc23..6298c61 100644 --- a/Services/AppSettings.cs +++ b/Services/AppSettings.cs @@ -23,7 +23,12 @@ public class AppSettings public List HiddenApps { get; set; } = new List(); public List ManuallyHiddenApps { get; set; } = new List(); public string AppsPath { get; set; } = string.Empty; - public string GitHubApiToken { get; set; } = string.Empty; + [System.Text.Json.Serialization.JsonIgnore] + public string GitHubApiToken + { + get => SecretStore.ReadToken(); + set => SecretStore.WriteToken(value); + } public string SortBy { get; set; } = "LastPlayed"; public bool StartFullscreen { get; set; } = false; public bool CloseAfterLaunch { get; set; } = false; diff --git a/Services/SecretStore.cs b/Services/SecretStore.cs new file mode 100644 index 0000000..aa96624 --- /dev/null +++ b/Services/SecretStore.cs @@ -0,0 +1,180 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; + +namespace GithubLauncher +{ + /// + /// Securely stores the GitHub API token using AES-256-GCM encryption. + /// The encrypted blob is stored outside the app directory with restricted + /// file permissions (0600 on Unix, inherited ACL on Windows). + /// This prevents casual token theft via settings.json exposure. + /// + public static class SecretStore + { + private const string DataDirName = "GithubLauncher"; + private const string TokenFileName = "github_token.enc"; + private const string KeyFileName = "github_token.key"; + + private static readonly string DataDir; + private static readonly string TokenPath; + private static readonly string KeyPath; + + private static byte[]? _cachedKey; + private static string? _cachedToken; + + static SecretStore() + { + var baseDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (string.IsNullOrEmpty(baseDir)) + baseDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".local", "share"); + DataDir = Path.Combine(baseDir, DataDirName); + TokenPath = Path.Combine(DataDir, TokenFileName); + KeyPath = Path.Combine(DataDir, KeyFileName); + } + + public static string ReadToken() + { + if (_cachedToken != null) + return _cachedToken; + + try + { + if (!File.Exists(TokenPath) || !File.Exists(KeyPath)) + return string.Empty; + + byte[] key = File.ReadAllBytes(KeyPath); + byte[] ciphertext = File.ReadAllBytes(TokenPath); + + string? token = Decrypt(ciphertext, key); + _cachedToken = token ?? string.Empty; + return _cachedToken; + } + catch + { + return string.Empty; + } + } + + public static void WriteToken(string? token) + { + try + { + Directory.CreateDirectory(DataDir); + + if (string.IsNullOrEmpty(token)) + { + if (File.Exists(TokenPath)) File.Delete(TokenPath); + if (File.Exists(KeyPath)) File.Delete(KeyPath); + _cachedToken = string.Empty; + return; + } + + byte[] key = GenerateOrLoadKey(); + byte[] ciphertext = Encrypt(token, key); + File.WriteAllBytes(TokenPath, ciphertext); + SetRestrictedPermissions(TokenPath); + + _cachedToken = token; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"SecretStore: failed to write token: {ex.Message}"); + throw; + } + } + + public static void ClearToken() + { + WriteToken(null); + } + + public static bool HasToken() + { + return !string.IsNullOrEmpty(ReadToken()); + } + + private static byte[] GenerateOrLoadKey() + { + if (_cachedKey != null) + return _cachedKey; + + if (File.Exists(KeyPath)) + { + _cachedKey = File.ReadAllBytes(KeyPath); + if (_cachedKey.Length == 32) + return _cachedKey; + } + + byte[] key = RandomNumberGenerator.GetBytes(32); + File.WriteAllBytes(KeyPath, key); + SetRestrictedPermissions(KeyPath); + _cachedKey = key; + return key; + } + + private static byte[] Encrypt(string plaintext, byte[] key) + { + byte[] nonce = RandomNumberGenerator.GetBytes(12); + byte[] plainBytes = Encoding.UTF8.GetBytes(plaintext); + byte[] ciphertext = new byte[nonce.Length + plainBytes.Length + 16]; + + using var aes = new AesGcm(key); + Array.Copy(nonce, 0, ciphertext, 0, nonce.Length); + aes.Encrypt(nonce, plainBytes, + ciphertext.AsSpan(nonce.Length, plainBytes.Length), + ciphertext.AsSpan(nonce.Length + plainBytes.Length, 16)); + + return ciphertext; + } + + private static string? Decrypt(byte[] ciphertext, byte[] key) + { + try + { + const int nonceSize = 12; + const int tagSize = 16; + + if (ciphertext.Length < nonceSize + tagSize) + return null; + + byte[] nonce = new byte[nonceSize]; + byte[] tag = new byte[tagSize]; + int dataLen = ciphertext.Length - nonceSize - tagSize; + byte[] data = new byte[dataLen]; + + Array.Copy(ciphertext, 0, nonce, 0, nonceSize); + Array.Copy(ciphertext, nonceSize, data, 0, dataLen); + Array.Copy(ciphertext, nonceSize + dataLen, tag, 0, tagSize); + + byte[] plainBytes = new byte[dataLen]; + using var aes = new AesGcm(key); + aes.Decrypt(nonce, data, tag, plainBytes); + + return Encoding.UTF8.GetString(plainBytes); + } + catch + { + return null; + } + } + + private static void SetRestrictedPermissions(string path) + { + try + { + if (OperatingSystem.IsWindows()) + return; + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + catch (PlatformNotSupportedException) { } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"SecretStore: failed to set permissions on {path}: {ex.Message}"); + } + } + } +}