using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.Logging; namespace OTSSignsOrchestrator.Desktop.Services; /// /// Stores and retrieves operator JWT and refresh tokens using the OS credential store. /// Windows: advapi32 Credential Manager; macOS: Security.framework Keychain; /// Linux: AES-encrypted file fallback in AppData. /// public sealed class TokenStoreService { private const string ServiceName = "OTSSignsOrchestrator"; private const string JwtAccount = "operator-jwt"; private const string RefreshAccount = "operator-refresh"; private readonly ILogger _logger; public TokenStoreService(ILogger logger) { _logger = logger; } public void StoreTokens(string jwt, string refreshToken) { WriteCredential(JwtAccount, jwt); WriteCredential(RefreshAccount, refreshToken); _logger.LogDebug("Tokens stored in OS credential store"); } public string? GetJwt() => ReadCredential(JwtAccount); public string? GetRefreshToken() => ReadCredential(RefreshAccount); public void ClearTokens() { DeleteCredential(JwtAccount); DeleteCredential(RefreshAccount); _logger.LogDebug("Tokens cleared from OS credential store"); } // ── Platform dispatch ──────────────────────────────────────────────────── private void WriteCredential(string account, string secret) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) WindowsCredentialManager.Write(ServiceName, account, secret); else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) MacKeychain.Write(ServiceName, account, secret); else LinuxEncryptedFile.Write(ServiceName, account, secret); } private string? ReadCredential(string account) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return WindowsCredentialManager.Read(ServiceName, account); if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) return MacKeychain.Read(ServiceName, account); return LinuxEncryptedFile.Read(ServiceName, account); } private void DeleteCredential(string account) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) WindowsCredentialManager.Delete(ServiceName, account); else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) MacKeychain.Delete(ServiceName, account); else LinuxEncryptedFile.Delete(ServiceName, account); } // ═══════════════════════════════════════════════════════════════════════ // Windows — advapi32.dll Credential Manager // ═══════════════════════════════════════════════════════════════════════ private static class WindowsCredentialManager { private const int CredTypeGeneric = 1; private const int CredPersistLocalMachine = 2; [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct CREDENTIAL { public uint Flags; public uint Type; public string TargetName; public string Comment; public long LastWritten; public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist; public uint AttributeCount; public IntPtr Attributes; public string TargetAlias; public string UserName; } [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool CredWriteW(ref CREDENTIAL credential, uint flags); [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool CredReadW(string target, uint type, uint flags, out IntPtr credential); [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] private static extern bool CredDeleteW(string target, uint type, uint flags); [DllImport("advapi32.dll")] private static extern void CredFree(IntPtr buffer); private static string TargetName(string service, string account) => $"{service}/{account}"; public static void Write(string service, string account, string secret) { var bytes = Encoding.UTF8.GetBytes(secret); var handle = GCHandle.Alloc(bytes, GCHandleType.Pinned); try { var cred = new CREDENTIAL { Type = CredTypeGeneric, TargetName = TargetName(service, account), UserName = account, CredentialBlob = handle.AddrOfPinnedObject(), CredentialBlobSize = (uint)bytes.Length, Persist = CredPersistLocalMachine, }; CredWriteW(ref cred, 0); } finally { handle.Free(); } } public static string? Read(string service, string account) { if (!CredReadW(TargetName(service, account), CredTypeGeneric, 0, out var credPtr)) return null; try { var cred = Marshal.PtrToStructure(credPtr); if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero) return null; var bytes = new byte[cred.CredentialBlobSize]; Marshal.Copy(cred.CredentialBlob, bytes, 0, bytes.Length); return Encoding.UTF8.GetString(bytes); } finally { CredFree(credPtr); } } public static void Delete(string service, string account) => CredDeleteW(TargetName(service, account), CredTypeGeneric, 0); } // ═══════════════════════════════════════════════════════════════════════ // macOS — Security.framework Keychain via /usr/bin/security CLI // ═══════════════════════════════════════════════════════════════════════ private static class MacKeychain { public static void Write(string service, string account, string secret) { // Delete first to avoid "duplicate" errors on update Delete(service, account); RunSecurity($"add-generic-password -s \"{service}\" -a \"{account}\" -w \"{EscapeShell(secret)}\" -U"); } public static string? Read(string service, string account) { var (exitCode, stdout) = RunSecurity($"find-generic-password -s \"{service}\" -a \"{account}\" -w"); return exitCode == 0 ? stdout.Trim() : null; } public static void Delete(string service, string account) => RunSecurity($"delete-generic-password -s \"{service}\" -a \"{account}\""); private static (int exitCode, string stdout) RunSecurity(string args) { using var proc = new System.Diagnostics.Process(); proc.StartInfo = new System.Diagnostics.ProcessStartInfo { FileName = "/usr/bin/security", Arguments = args, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, }; proc.Start(); var stdout = proc.StandardOutput.ReadToEnd(); proc.WaitForExit(5000); return (proc.ExitCode, stdout); } private static string EscapeShell(string s) => s.Replace("\\", "\\\\").Replace("\"", "\\\""); } // ═══════════════════════════════════════════════════════════════════════ // Linux — AES-256-GCM encrypted file in ~/.local/share // ═══════════════════════════════════════════════════════════════════════ private static class LinuxEncryptedFile { // Machine-specific key derived from machine-id + user name private static byte[] DeriveKey() { var machineId = "linux-default"; try { if (File.Exists("/etc/machine-id")) machineId = File.ReadAllText("/etc/machine-id").Trim(); } catch { /* fallback */ } var material = $"{machineId}:{Environment.UserName}:{ServiceName}"; return SHA256.HashData(Encoding.UTF8.GetBytes(material)); } private static string FilePath(string service, string account) { var dir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), service, "credentials"); Directory.CreateDirectory(dir); return Path.Combine(dir, $"{account}.enc"); } public static void Write(string service, string account, string secret) { var key = DeriveKey(); var plaintext = Encoding.UTF8.GetBytes(secret); var nonce = new byte[12]; RandomNumberGenerator.Fill(nonce); var ciphertext = new byte[plaintext.Length]; var tag = new byte[16]; using var aes = new AesGcm(key, 16); aes.Encrypt(nonce, plaintext, ciphertext, tag); // File format: [12 nonce][16 tag][ciphertext] var output = new byte[12 + 16 + ciphertext.Length]; nonce.CopyTo(output, 0); tag.CopyTo(output, 12); ciphertext.CopyTo(output, 28); File.WriteAllBytes(FilePath(service, account), output); } public static string? Read(string service, string account) { var path = FilePath(service, account); if (!File.Exists(path)) return null; var data = File.ReadAllBytes(path); if (data.Length < 28) return null; var nonce = data[..12]; var tag = data[12..28]; var ciphertext = data[28..]; var plaintext = new byte[ciphertext.Length]; using var aes = new AesGcm(DeriveKey(), 16); aes.Decrypt(nonce, ciphertext, tag, plaintext); return Encoding.UTF8.GetString(plaintext); } public static void Delete(string service, string account) { var path = FilePath(service, account); if (File.Exists(path)) File.Delete(path); } } }