diff --git a/src/CFamily.UnitTests/packages.lock.json b/src/CFamily.UnitTests/packages.lock.json index 975bb3b8e6..99ffa2587d 100644 --- a/src/CFamily.UnitTests/packages.lock.json +++ b/src/CFamily.UnitTests/packages.lock.json @@ -1115,6 +1115,11 @@ "resolved": "4.3.0", "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==" }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.7", + "contentHash": "OAiDZTyzIzgbtrjzbMlprCxvlXpFW7q+JVOSEc/v4jgLBF4hVSey0MQ06MyctGjspKyJBdGj6k6MuqjiZV9c5Q==" + }, "System.Security.Cryptography.X509Certificates": { "type": "Transitive", "resolved": "4.3.0", @@ -1286,7 +1291,8 @@ "SonarLint.VisualStudio.Core": "[1.0.0, )", "SonarLint.VisualStudio.IssueVisualization": "[1.0.0, )", "SonarLint.VisualStudio.SLCore": "[1.0.0, )", - "StrongNamer": "[0.0.8, )" + "StrongNamer": "[0.0.8, )", + "System.Security.Cryptography.ProtectedData": "[9.0.7, )" } }, "SonarLint.VisualStudio.Core": { diff --git a/src/ConnectedMode.UnitTests/Persistence/ServerConnectionsRepositoryTests.cs b/src/ConnectedMode.UnitTests/Persistence/ServerConnectionsRepositoryTests.cs index 1ca940b83f..779bc209bf 100644 --- a/src/ConnectedMode.UnitTests/Persistence/ServerConnectionsRepositoryTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/ServerConnectionsRepositoryTests.cs @@ -61,7 +61,8 @@ public void MefCtor_CheckExports() => MefTestHelpers.CheckTypeCanBeImported( MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport(), - MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport()); [TestMethod] @@ -69,7 +70,8 @@ public void MefCtor_IServerConnectionWithInvalidTokenRepository_CheckExports() = MefTestHelpers.CheckTypeCanBeImported( MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport(), - MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport()); [TestMethod] diff --git a/src/ConnectedMode.UnitTests/Persistence/SolutionBindingRepositoryTests.cs b/src/ConnectedMode.UnitTests/Persistence/SolutionBindingRepositoryTests.cs index 47973d48fc..f3a904e27d 100644 --- a/src/ConnectedMode.UnitTests/Persistence/SolutionBindingRepositoryTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/SolutionBindingRepositoryTests.cs @@ -56,7 +56,7 @@ public void TestInitialize() solutionBindingFileLoader = Substitute.For(); logger = new TestLogger(); - testSubject = new SolutionBindingRepository(unintrusiveBindingPathProvider, bindingJsonModelConverter, serverConnectionsRepository, solutionBindingFileLoader, credentialsLoader, logger); + testSubject = new SolutionBindingRepository(unintrusiveBindingPathProvider, bindingJsonModelConverter, serverConnectionsRepository, credentialsLoader, solutionBindingFileLoader, logger); mockCredentials = new UsernameAndPasswordCredentials("user", "pwd".ToSecureString()); @@ -72,13 +72,15 @@ public void MefCtor_CheckIsExported() MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport(), - MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport()); MefTestHelpers.CheckTypeCanBeImported( MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport(), - MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), + MefTestHelpers.CreateExport(), MefTestHelpers.CreateExport()); } diff --git a/src/ConnectedMode.UnitTests/packages.lock.json b/src/ConnectedMode.UnitTests/packages.lock.json index eb0a0b7c95..31fb288868 100644 --- a/src/ConnectedMode.UnitTests/packages.lock.json +++ b/src/ConnectedMode.UnitTests/packages.lock.json @@ -1334,6 +1334,11 @@ "resolved": "4.3.0", "contentHash": "7bDIyVFNL/xKeFHjhobUAQqSpJq9YTOpbEs6mR233Et01STBMXNAc/V+BM6dwYGc95gVh/Zf+iVXWzj3mE8DWg==" }, + "System.Security.Cryptography.ProtectedData": { + "type": "Transitive", + "resolved": "9.0.7", + "contentHash": "OAiDZTyzIzgbtrjzbMlprCxvlXpFW7q+JVOSEc/v4jgLBF4hVSey0MQ06MyctGjspKyJBdGj6k6MuqjiZV9c5Q==" + }, "System.Security.Cryptography.X509Certificates": { "type": "Transitive", "resolved": "4.3.0", @@ -1483,7 +1488,8 @@ "SonarLint.VisualStudio.Core": "[1.0.0, )", "SonarLint.VisualStudio.IssueVisualization": "[1.0.0, )", "SonarLint.VisualStudio.SLCore": "[1.0.0, )", - "StrongNamer": "[0.0.8, )" + "StrongNamer": "[0.0.8, )", + "System.Security.Cryptography.ProtectedData": "[9.0.7, )" } }, "SonarLint.VisualStudio.Core": { diff --git a/src/ConnectedMode/ConnectedMode.csproj b/src/ConnectedMode/ConnectedMode.csproj index 9143f1fe3e..0290fbff56 100644 --- a/src/ConnectedMode/ConnectedMode.csproj +++ b/src/ConnectedMode/ConnectedMode.csproj @@ -93,6 +93,7 @@ + @@ -171,6 +172,16 @@ + + + + + + + MSBuild:Compile + + + diff --git a/src/ConnectedMode/CredentialStore2/CredentialStore2.cs b/src/ConnectedMode/CredentialStore2/CredentialStore2.cs new file mode 100644 index 0000000000..c9430ddb70 --- /dev/null +++ b/src/ConnectedMode/CredentialStore2/CredentialStore2.cs @@ -0,0 +1,317 @@ +/* + * SonarLint for Visual Studio + * Copyright (C) 2016-2025 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +using System.ComponentModel.Composition; +using System.IO; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using System.Windows; +using Newtonsoft.Json; +using SonarLint.VisualStudio.ConnectedMode.Persistence; +using SonarLint.VisualStudio.ConnectedMode.UI; +using SonarLint.VisualStudio.Core; +using SonarLint.VisualStudio.Core.Binding; +using SonarLint.VisualStudio.Core.Synchronization; +using SonarLint.VisualStudio.Core.SystemAbstractions; +using SonarQube.Client.Helpers; + +namespace SonarLint.VisualStudio.ConnectedMode.CredentialStore2; + +internal class CredentialDto +{ + public Uri Uri { get; init; } + public string EncryptedToken { get; init; } +} + +[Export(typeof(ISolutionBindingCredentialsLoader))] +[PartCreationPolicy(CreationPolicy.Shared)] +public class CredentialStore2 : ISolutionBindingCredentialsLoader, IDisposable +{ + private readonly IFileSystemService fileSystem; + private readonly IThreadHandling threadHandling; + private readonly string storagePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SLVS_Credentials", "credentials.json"); + private bool disposed = false; + private ILogger log; + private MasterPasswordManager masterPasswordManager; + + [ImportingConstructor] + public CredentialStore2( + IFileSystemService fileSystem, + IThreadHandling threadHandling, + ILogger log) + { + this.fileSystem = fileSystem; + this.threadHandling = threadHandling; + this.log = log; + masterPasswordManager = new MasterPasswordManager(threadHandling); + } + + public void DeleteCredentials(Uri targetUri) + { + ThrowIfDisposed(); + + if (targetUri == null || !fileSystem.File.Exists(storagePath)) + { + return; + } + + var allText = fileSystem.File.ReadAllText(storagePath); + var dictionary = JsonConvert.DeserializeObject>(allText); + + if (dictionary != null && dictionary.Remove(targetUri)) + { + var serializedDictionary = JsonConvert.SerializeObject(dictionary); + fileSystem.File.WriteAllText(storagePath, serializedDictionary); + } + } + + public IConnectionCredentials Load(Uri boundServerUri) + { + ThrowIfDisposed(); + + string encryptedToken = ReadToken(boundServerUri); + + if (encryptedToken == null) + { + return null; + } + + var secureToken = GetSecureString(encryptedToken); + + if (secureToken == null) + { + return null; + } + + return new TokenAuthCredentials(secureToken); + } + + public void Save(IConnectionCredentials credentials, Uri boundServerUri) + { + ThrowIfDisposed(); + + if (credentials is not ITokenCredentials tokenCredentials) + { + throw new ArgumentException("Only token credentials are supported", nameof(credentials)); + } + + var tokenProtectedBytes = UseMasterPasswordSafe(masterPasswordBytes => + { + byte[] tokenUnprotected = null; + byte[] tokenProtected = null; + try + { + tokenUnprotected = Encoding.UTF8.GetBytes(tokenCredentials.Token.ToUnsecureString()); + tokenProtected = ProtectedData.Protect( + tokenUnprotected, + masterPasswordBytes, + DataProtectionScope.LocalMachine); + } + finally + { + Clear(tokenUnprotected); + } + + return tokenProtected; + }); + + WriteToken(boundServerUri, Convert.ToBase64String(tokenProtectedBytes)); + } + + private SecureString GetSecureString(string encryptedToken) + { + SecureString secureToken = new SecureString(); + byte[] tokenUnprotectedBytes = null; + string unprotectedString; + try + { + tokenUnprotectedBytes = UseMasterPasswordSafe(masterPasswordBytes => + ProtectedData.Unprotect( + Convert.FromBase64String(encryptedToken), + masterPasswordBytes, + DataProtectionScope.LocalMachine)); + unprotectedString = Encoding.UTF8.GetString(tokenUnprotectedBytes); + } + catch (Exception e) when (!ErrorHandler.IsCriticalException(e)) + { + log.WriteLine(e.ToString()); + masterPasswordManager.Reset(); + return null; + } + finally + { + Clear(tokenUnprotectedBytes); + } + + foreach (var character in unprotectedString) + { + secureToken.AppendChar(character); + } + secureToken.MakeReadOnly(); + + return secureToken; + } + + private byte[] UseMasterPasswordSafe(Func operation) + { + byte[] masterPasswordUnprotectedBytes = null; + byte[] result = null; + try + { + var masterPassword = masterPasswordManager.EnsureMasterPasswordInitialized(); + if (masterPassword == null || masterPassword.Length == 0) + { + throw new InvalidOperationException("Master password is required but was not provided"); + } + + masterPasswordUnprotectedBytes = Encoding.UTF8.GetBytes(masterPassword.ToUnsecureString()); + result = operation(masterPasswordUnprotectedBytes); + } + finally + { + Clear(masterPasswordUnprotectedBytes); + } + return result; + } + + private string ReadToken(Uri targetUri) + { + if (fileSystem.File.Exists(storagePath)) + { + var allText = fileSystem.File.ReadAllText(storagePath); + var dictionary = JsonConvert.DeserializeObject>(allText); + if (dictionary != null && dictionary.TryGetValue(targetUri, out var dto)) + { + return dto.EncryptedToken; + } + } + + return null; + } + + private void WriteToken(Uri targetUri, string token) + { + Dictionary dictionary; + + if (fileSystem.File.Exists(storagePath)) + { + var allText = fileSystem.File.ReadAllText(storagePath); + dictionary = JsonConvert.DeserializeObject>(allText) ?? new Dictionary(); + } + else + { + dictionary = new Dictionary(); + + var directory = Path.GetDirectoryName(storagePath); + if (!fileSystem.Directory.Exists(directory)) + { + fileSystem.Directory.CreateDirectory(directory); + } + } + + dictionary[targetUri] = new CredentialDto { Uri = targetUri, EncryptedToken = token }; + var serializedDictionary = JsonConvert.SerializeObject(dictionary); + + fileSystem.File.WriteAllText(storagePath, serializedDictionary); + } + + private void Clear(byte[] array) + { + if (array is null) + { + return; + } + Array.Clear(array, 0, array.Length); + } + + private void ThrowIfDisposed() + { + if (disposed) + { + throw new ObjectDisposedException(nameof(CredentialStore2)); + } + } + + public void Dispose() + { + if (disposed) + { + return; + } + + masterPasswordManager.Dispose(); + disposed = true; + } + + private class MasterPasswordManager + { + private SecureString masterPassword; + private readonly IThreadHandling threadHandling; + private readonly object lockObj = new object(); + + public MasterPasswordManager(IThreadHandling threadHandling) + { + this.threadHandling = threadHandling; + } + + public SecureString EnsureMasterPasswordInitialized() + { + var updatedPassword = null as SecureString; + threadHandling.RunOnUIThread(() => + { + lock (lockObj) + { + if (masterPassword != null) + { + updatedPassword = masterPassword; + return; + } + + var dialog = new MasterPasswordDialog(); + var dialogResult = dialog.ShowDialog(Application.Current.MainWindow); // need to make this show only once + + if (dialogResult.HasValue && dialogResult.Value) + { + updatedPassword = masterPassword = dialog.MasterPassword; + } + } + }); + + return updatedPassword; + } + + public void Reset() + { + lock (lockObj) + { + masterPassword = null; + } + } + + public void Dispose() + { + lock (lockObj) + { + masterPassword?.Dispose(); + } + } + } +} diff --git a/src/ConnectedMode/CredentialStore2/MasterPasswordDialog.xaml b/src/ConnectedMode/CredentialStore2/MasterPasswordDialog.xaml new file mode 100644 index 0000000000..45ec9733d7 --- /dev/null +++ b/src/ConnectedMode/CredentialStore2/MasterPasswordDialog.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + +