// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Security.Cryptography; using System.Text; using Microsoft.Maui.Storage; namespace Microsoft.Maui.Platform.Linux.Services; /// /// Linux secure storage implementation using secret-tool (libsecret) or encrypted file fallback. /// public class SecureStorageService : ISecureStorage { private const string ServiceName = "maui-secure-storage"; private const string FallbackDirectory = ".maui-secure"; private readonly string _fallbackPath; private readonly bool _useSecretService; public SecureStorageService() { _fallbackPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), FallbackDirectory); _useSecretService = CheckSecretServiceAvailable(); } private bool CheckSecretServiceAvailable() { try { var startInfo = new ProcessStartInfo { FileName = "which", Arguments = "secret-tool", UseShellExecute = false, RedirectStandardOutput = true, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) return false; process.WaitForExit(); return process.ExitCode == 0; } catch { return false; } } public Task GetAsync(string key) { if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); if (_useSecretService) { return GetFromSecretServiceAsync(key); } else { return GetFromFallbackAsync(key); } } public Task SetAsync(string key, string value) { if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); if (_useSecretService) { return SetInSecretServiceAsync(key, value); } else { return SetInFallbackAsync(key, value); } } public bool Remove(string key) { if (string.IsNullOrEmpty(key)) throw new ArgumentNullException(nameof(key)); if (_useSecretService) { return RemoveFromSecretService(key); } else { return RemoveFromFallback(key); } } public void RemoveAll() { if (_useSecretService) { // Cannot easily remove all from secret service without knowing all keys // This would require additional tracking } else { if (Directory.Exists(_fallbackPath)) { Directory.Delete(_fallbackPath, true); } } } #region Secret Service (libsecret) private async Task GetFromSecretServiceAsync(string key) { try { var startInfo = new ProcessStartInfo { FileName = "secret-tool", Arguments = $"lookup service {ServiceName} key {EscapeArg(key)}", UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) return null; var output = await process.StandardOutput.ReadToEndAsync(); await process.WaitForExitAsync(); if (process.ExitCode == 0 && !string.IsNullOrEmpty(output)) { return output.TrimEnd('\n'); } return null; } catch { return null; } } private async Task SetInSecretServiceAsync(string key, string value) { try { var startInfo = new ProcessStartInfo { FileName = "secret-tool", Arguments = $"store --label=\"{EscapeArg(key)}\" service {ServiceName} key {EscapeArg(key)}", UseShellExecute = false, RedirectStandardInput = true, RedirectStandardError = true, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) throw new InvalidOperationException("Failed to start secret-tool"); await process.StandardInput.WriteAsync(value); process.StandardInput.Close(); await process.WaitForExitAsync(); if (process.ExitCode != 0) { var error = await process.StandardError.ReadToEndAsync(); throw new InvalidOperationException($"Failed to store secret: {error}"); } } catch (Exception ex) when (ex is not InvalidOperationException) { // Fall back to file storage await SetInFallbackAsync(key, value); } } private bool RemoveFromSecretService(string key) { try { var startInfo = new ProcessStartInfo { FileName = "secret-tool", Arguments = $"clear service {ServiceName} key {EscapeArg(key)}", UseShellExecute = false, CreateNoWindow = true }; using var process = Process.Start(startInfo); if (process == null) return false; process.WaitForExit(); return process.ExitCode == 0; } catch { return false; } } #endregion #region Fallback Encrypted Storage private async Task GetFromFallbackAsync(string key) { var filePath = GetFallbackFilePath(key); if (!File.Exists(filePath)) return null; try { var encryptedData = await File.ReadAllBytesAsync(filePath); return DecryptData(encryptedData); } catch { return null; } } private async Task SetInFallbackAsync(string key, string value) { EnsureFallbackDirectory(); var filePath = GetFallbackFilePath(key); var encryptedData = EncryptData(value); await File.WriteAllBytesAsync(filePath, encryptedData); // Set restrictive permissions File.SetUnixFileMode(filePath, UnixFileMode.UserRead | UnixFileMode.UserWrite); } private bool RemoveFromFallback(string key) { var filePath = GetFallbackFilePath(key); if (File.Exists(filePath)) { File.Delete(filePath); return true; } return false; } private string GetFallbackFilePath(string key) { // Hash the key to create a safe filename using var sha256 = SHA256.Create(); var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(key)); var fileName = Convert.ToHexString(hash).ToLowerInvariant(); return Path.Combine(_fallbackPath, fileName); } private void EnsureFallbackDirectory() { if (!Directory.Exists(_fallbackPath)) { Directory.CreateDirectory(_fallbackPath); // Set restrictive permissions on the directory File.SetUnixFileMode(_fallbackPath, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } } private byte[] EncryptData(string data) { // Use a machine-specific key derived from machine ID var key = GetMachineKey(); using var aes = Aes.Create(); aes.Key = key; aes.GenerateIV(); using var encryptor = aes.CreateEncryptor(); var plainBytes = Encoding.UTF8.GetBytes(data); var encryptedBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); // Prepend IV to encrypted data var result = new byte[aes.IV.Length + encryptedBytes.Length]; Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length); Buffer.BlockCopy(encryptedBytes, 0, result, aes.IV.Length, encryptedBytes.Length); return result; } private string DecryptData(byte[] encryptedData) { var key = GetMachineKey(); using var aes = Aes.Create(); aes.Key = key; // Extract IV from beginning of data var iv = new byte[aes.BlockSize / 8]; Buffer.BlockCopy(encryptedData, 0, iv, 0, iv.Length); aes.IV = iv; var cipherText = new byte[encryptedData.Length - iv.Length]; Buffer.BlockCopy(encryptedData, iv.Length, cipherText, 0, cipherText.Length); using var decryptor = aes.CreateDecryptor(); var plainBytes = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length); return Encoding.UTF8.GetString(plainBytes); } private byte[] GetMachineKey() { // Derive a key from machine-id and user var machineId = GetMachineId(); var user = Environment.UserName; var combined = $"{machineId}:{user}:{ServiceName}"; using var sha256 = SHA256.Create(); return sha256.ComputeHash(Encoding.UTF8.GetBytes(combined)); } private string GetMachineId() { try { // Try /etc/machine-id first (systemd) if (File.Exists("/etc/machine-id")) { return File.ReadAllText("/etc/machine-id").Trim(); } // Try /var/lib/dbus/machine-id (older systems) if (File.Exists("/var/lib/dbus/machine-id")) { return File.ReadAllText("/var/lib/dbus/machine-id").Trim(); } // Fallback to hostname return Environment.MachineName; } catch { return Environment.MachineName; } } #endregion private static string EscapeArg(string arg) { return arg.Replace("\"", "\\\"").Replace("'", "\\'"); } }