diff --git a/src/CFamily.UnitTests/packages.lock.json b/src/CFamily.UnitTests/packages.lock.json index 7028ffbafa..fae1156438 100644 --- a/src/CFamily.UnitTests/packages.lock.json +++ b/src/CFamily.UnitTests/packages.lock.json @@ -1121,6 +1121,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", @@ -1292,7 +1297,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/SolutionBindingCredentialsLoaderTests.cs b/src/ConnectedMode.UnitTests/Persistence/SolutionBindingCredentialsLoaderTests.cs index 5efa1d7d71..222b64a1b4 100644 --- a/src/ConnectedMode.UnitTests/Persistence/SolutionBindingCredentialsLoaderTests.cs +++ b/src/ConnectedMode.UnitTests/Persistence/SolutionBindingCredentialsLoaderTests.cs @@ -1,185 +1,185 @@ -/* - * 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 Microsoft.Alm.Authentication; -using SonarLint.VisualStudio.ConnectedMode.Binding; -using SonarLint.VisualStudio.ConnectedMode.Persistence; -using SonarLint.VisualStudio.Core.Binding; -using SonarQube.Client.Helpers; - -namespace SonarLint.VisualStudio.ConnectedMode.UnitTests.Persistence -{ - [TestClass] - public class SolutionBindingCredentialsLoaderTests - { - private ICredentialStoreService store; - private Uri mockUri; - private SolutionBindingCredentialsLoader testSubject; - - [TestInitialize] - public void Setup() - { - store = Substitute.For(); - mockUri = new Uri("http://sonarsource.com"); - testSubject = new SolutionBindingCredentialsLoader(store); - } - - [TestMethod] - public void Ctor_NullStore_Exception() - { - Action act = () => new SolutionBindingCredentialsLoader(null); - - act.Should().ThrowExactly().And.ParamName.Should().Be("store"); - } - - [TestMethod] - public void Load_ServerUriIsNull_Null() - { - var actual = testSubject.Load(null); - - actual.Should().Be(null); - } - - [TestMethod] - public void Load_NoCredentials_Null() - { - MockReadCredentials(mockUri, null); - - var actual = testSubject.Load(mockUri); - - actual.Should().Be(null); - } - - [TestMethod] - public void Load_CredentialsExist_CredentialsWithSecuredString() - { - var credentials = new Credential("user", "password"); - MockReadCredentials(mockUri, credentials); - - var actual = testSubject.Load(mockUri); - - actual.Should().BeEquivalentTo(new UsernameAndPasswordCredentials("user", "password".ToSecureString())); - } - - [TestMethod] - public void Load_CredentialsExist_UsernameIsEmpty_BasicAuthCredentialsWithSecuredString() - { - var credentials = new Credential(string.Empty, "token"); - MockReadCredentials(mockUri, credentials); - - var actual = testSubject.Load(mockUri); - - actual.Should().BeEquivalentTo(new UsernameAndPasswordCredentials(string.Empty, "token".ToSecureString())); - } - - /// - /// For backward compatibility - /// - [TestMethod] - public void Load_CredentialsExist_PasswordIsEmpty_TokenCredentialsWithSecuredString() - { - var credentials = new Credential("token", string.Empty); - MockReadCredentials(mockUri, credentials); - - var actual = testSubject.Load(mockUri); - - actual.Should().BeEquivalentTo(new TokenAuthCredentials("token".ToSecureString())); - } - - [TestMethod] - public void Save_ServerUriIsNull_CredentialsNotSaved() - { - var credentials = new UsernameAndPasswordCredentials("user", "password".ToSecureString()); - - testSubject.Save(credentials, null); - - store.DidNotReceive().WriteCredentials(Arg.Any(), Arg.Any()); - } - - [TestMethod] - public void Save_CredentialsAreNull_CredentialsNotSaved() - { - testSubject.Save(null, mockUri); - - store.DidNotReceive().WriteCredentials(Arg.Any(), Arg.Any()); - } - - [TestMethod] - public void Save_CredentialsAreNotBasicAuth_CredentialsNotSaved() - { - try - { - testSubject.Save(new Mock().Object, mockUri); - } - catch (Exception) - { - // ignored - } - - store.DidNotReceive().WriteCredentials(Arg.Any(), Arg.Any()); - } - - [TestMethod] - public void Save_CredentialsAreBasicAuth_CredentialsSavedWithUnsecuredString() - { - var credentials = new UsernameAndPasswordCredentials("user", "password".ToSecureString()); - testSubject.Save(credentials, mockUri); - - store.Received(1) - .WriteCredentials( - Arg.Is(t => t.ActualUri == mockUri), - Arg.Is(c => c.Username == "user" && c.Password == "password")); - } - - [TestMethod] - public void Save_CredentialsAreTokenAuth_CredentialsSavedWithUnsecuredString() - { - var credentials = new TokenAuthCredentials("token".ToSecureString()); - - testSubject.Save(credentials, mockUri); - - store.Received(1) - .WriteCredentials( - Arg.Is(t => t.ActualUri == mockUri), - Arg.Is(c => c.Username == "token" && c.Password == string.Empty)); - } - - [TestMethod] - public void DeleteCredentials_UriNull_DoesNotCallStoreDeleteCredentials() - { - testSubject.DeleteCredentials(null); - - store.DidNotReceive().DeleteCredentials(Arg.Any()); - } - - [TestMethod] - public void DeleteCredentials_UriProvided_CallsStoreDeleteCredentials() - { - testSubject.DeleteCredentials(mockUri); - - store.Received(1).DeleteCredentials(Arg.Any()); - } - - private void MockReadCredentials(Uri uri, Credential credentials) => - store - .ReadCredentials(Arg.Is(t => t.ActualUri == uri)) - .Returns(credentials); - } -} +// /* +// * 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 Microsoft.Alm.Authentication; +// using SonarLint.VisualStudio.ConnectedMode.Binding; +// using SonarLint.VisualStudio.ConnectedMode.Persistence; +// using SonarLint.VisualStudio.Core.Binding; +// using SonarQube.Client.Helpers; +// +// namespace SonarLint.VisualStudio.ConnectedMode.UnitTests.Persistence +// { +// [TestClass] +// public class DefaultBindingCredentialsLoaderTests +// { +// private ICredentialStoreService store; +// private Uri mockUri; +// private DefaultBindingCredentialsLoader testSubject; +// +// [TestInitialize] +// public void Setup() +// { +// store = Substitute.For(); +// mockUri = new Uri("http://sonarsource.com"); +// testSubject = new DefaultBindingCredentialsLoader(store); +// } +// +// [TestMethod] +// public void Ctor_NullStore_Exception() +// { +// Action act = () => new DefaultBindingCredentialsLoader(null); +// +// act.Should().ThrowExactly().And.ParamName.Should().Be("store"); +// } +// +// [TestMethod] +// public void Load_ServerUriIsNull_Null() +// { +// var actual = testSubject.Load(null); +// +// actual.Should().Be(null); +// } +// +// [TestMethod] +// public void Load_NoCredentials_Null() +// { +// MockReadCredentials(mockUri, null); +// +// var actual = testSubject.Load(mockUri); +// +// actual.Should().Be(null); +// } +// +// [TestMethod] +// public void Load_CredentialsExist_CredentialsWithSecuredString() +// { +// var credentials = new Credential("user", "password"); +// MockReadCredentials(mockUri, credentials); +// +// var actual = testSubject.Load(mockUri); +// +// actual.Should().BeEquivalentTo(new UsernameAndPasswordCredentials("user", "password".ToSecureString())); +// } +// +// [TestMethod] +// public void Load_CredentialsExist_UsernameIsEmpty_BasicAuthCredentialsWithSecuredString() +// { +// var credentials = new Credential(string.Empty, "token"); +// MockReadCredentials(mockUri, credentials); +// +// var actual = testSubject.Load(mockUri); +// +// actual.Should().BeEquivalentTo(new UsernameAndPasswordCredentials(string.Empty, "token".ToSecureString())); +// } +// +// /// +// /// For backward compatibility +// /// +// [TestMethod] +// public void Load_CredentialsExist_PasswordIsEmpty_TokenCredentialsWithSecuredString() +// { +// var credentials = new Credential("token", string.Empty); +// MockReadCredentials(mockUri, credentials); +// +// var actual = testSubject.Load(mockUri); +// +// actual.Should().BeEquivalentTo(new TokenAuthCredentials("token".ToSecureString())); +// } +// +// [TestMethod] +// public void Save_ServerUriIsNull_CredentialsNotSaved() +// { +// var credentials = new UsernameAndPasswordCredentials("user", "password".ToSecureString()); +// +// testSubject.Save(credentials, null); +// +// store.DidNotReceive().WriteCredentials(Arg.Any(), Arg.Any()); +// } +// +// [TestMethod] +// public void Save_CredentialsAreNull_CredentialsNotSaved() +// { +// testSubject.Save(null, mockUri); +// +// store.DidNotReceive().WriteCredentials(Arg.Any(), Arg.Any()); +// } +// +// [TestMethod] +// public void Save_CredentialsAreNotBasicAuth_CredentialsNotSaved() +// { +// try +// { +// testSubject.Save(new Mock().Object, mockUri); +// } +// catch (Exception) +// { +// // ignored +// } +// +// store.DidNotReceive().WriteCredentials(Arg.Any(), Arg.Any()); +// } +// +// [TestMethod] +// public void Save_CredentialsAreBasicAuth_CredentialsSavedWithUnsecuredString() +// { +// var credentials = new UsernameAndPasswordCredentials("user", "password".ToSecureString()); +// testSubject.Save(credentials, mockUri); +// +// store.Received(1) +// .WriteCredentials( +// Arg.Is(t => t.ActualUri == mockUri), +// Arg.Is(c => c.Username == "user" && c.Password == "password")); +// } +// +// [TestMethod] +// public void Save_CredentialsAreTokenAuth_CredentialsSavedWithUnsecuredString() +// { +// var credentials = new TokenAuthCredentials("token".ToSecureString()); +// +// testSubject.Save(credentials, mockUri); +// +// store.Received(1) +// .WriteCredentials( +// Arg.Is(t => t.ActualUri == mockUri), +// Arg.Is(c => c.Username == "token" && c.Password == string.Empty)); +// } +// +// [TestMethod] +// public void DeleteCredentials_UriNull_DoesNotCallStoreDeleteCredentials() +// { +// testSubject.DeleteCredentials(null); +// +// store.DidNotReceive().DeleteCredentials(Arg.Any()); +// } +// +// [TestMethod] +// public void DeleteCredentials_UriProvided_CallsStoreDeleteCredentials() +// { +// testSubject.DeleteCredentials(mockUri); +// +// store.Received(1).DeleteCredentials(Arg.Any()); +// } +// +// private void MockReadCredentials(Uri uri, Credential credentials) => +// store +// .ReadCredentials(Arg.Is(t => t.ActualUri == uri)) +// .Returns(credentials); +// } +// } 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 d252cde329..ceaecb1ede 100644 --- a/src/ConnectedMode.UnitTests/packages.lock.json +++ b/src/ConnectedMode.UnitTests/packages.lock.json @@ -1324,6 +1324,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", @@ -1473,7 +1478,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 6e4c445304..fe49bbe922 100644 --- a/src/ConnectedMode/ConnectedMode.csproj +++ b/src/ConnectedMode/ConnectedMode.csproj @@ -96,6 +96,7 @@ + @@ -164,6 +165,16 @@ + + + + + + + MSBuild:Compile + + + diff --git a/src/ConnectedMode/CredentialStore2/CredentialStore2.cs b/src/ConnectedMode/CredentialStore2/CredentialStore2.cs new file mode 100644 index 0000000000..417694d425 --- /dev/null +++ b/src/ConnectedMode/CredentialStore2/CredentialStore2.cs @@ -0,0 +1,312 @@ +/* + * 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.SystemAbstractions; +using SonarLint.VisualStudio.Integration; +using SonarQube.Client.Helpers; + +namespace SonarLint.VisualStudio.ConnectedMode.CredentialStore2; + +internal class CredentialDto +{ + public Uri Uri { get; init; } + public string EncryptedToken { get; init; } +} + +[Export(typeof(ISolutionBindingCredentialsLoaderImpl))] +[PartCreationPolicy(CreationPolicy.Shared)] +[method: ImportingConstructor] +public class DpapiMasterPasswordCredentialsLoader( + IFileSystemService fileSystem, + IThreadHandling threadHandling, + ILogger log) : ISolutionBindingCredentialsLoaderImpl, IDisposable +{ + private readonly string storagePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SLVS_Credentials", "credentials_dpapimaster.json"); + private bool disposed = false; + private readonly ILogger log = log.ForVerboseContext(nameof(DpapiMasterPasswordCredentialsLoader)); + private readonly MasterPasswordManager masterPasswordManager = new(threadHandling); + + public CredentialStoreType StoreType => CredentialStoreType.DPAPIMasterPassword; + + 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)); + } + + public void Clear() => fileSystem.File.Delete(storagePath); + + 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(DpapiMasterPasswordCredentialsLoader)); + } + } + + 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/CredentialStore3.cs b/src/ConnectedMode/CredentialStore2/CredentialStore3.cs new file mode 100644 index 0000000000..4449eab066 --- /dev/null +++ b/src/ConnectedMode/CredentialStore2/CredentialStore3.cs @@ -0,0 +1,220 @@ +/* + * 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.SystemAbstractions; +using SonarLint.VisualStudio.Integration; +using SonarQube.Client.Helpers; + +namespace SonarLint.VisualStudio.ConnectedMode.CredentialStore2; + +[Export(typeof(ISolutionBindingCredentialsLoaderImpl))] +[PartCreationPolicy(CreationPolicy.Shared)] +[method: ImportingConstructor] +public class DpapiCurrentUserCredentialsLoader( + IFileSystemService fileSystem, + ILogger log) : ISolutionBindingCredentialsLoaderImpl, IDisposable +{ + private readonly string storagePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SLVS_Credentials", "credentials_dpapicurrentuser.json"); + private bool disposed = false; + private readonly ILogger log = log.ForVerboseContext(nameof(DpapiMasterPasswordCredentialsLoader)); + + public CredentialStoreType StoreType => CredentialStoreType.DPAPI; + + 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)); + } + + byte[] tokenUnprotected = null; + byte[] tokenProtected = null; + try + { + tokenUnprotected = Encoding.UTF8.GetBytes(tokenCredentials.Token.ToUnsecureString()); + tokenProtected = ProtectedData.Protect( + tokenUnprotected, + null, + DataProtectionScope.CurrentUser); + } + finally + { + Clear(tokenUnprotected); + } + + WriteToken(boundServerUri, Convert.ToBase64String(tokenProtected)); + } + + public void Clear() => fileSystem.File.Delete(storagePath); + + private SecureString GetSecureString(string encryptedToken) + { + SecureString secureToken = new SecureString(); + byte[] tokenUnprotectedBytes = null; + string unprotectedString; + try + { + tokenUnprotectedBytes = + ProtectedData.Unprotect( + Convert.FromBase64String(encryptedToken), + null, + DataProtectionScope.CurrentUser); + unprotectedString = Encoding.UTF8.GetString(tokenUnprotectedBytes); + } + catch (Exception e) when (!ErrorHandler.IsCriticalException(e)) + { + log.WriteLine(e.ToString()); + return null; + } + finally + { + Clear(tokenUnprotectedBytes); + } + + foreach (var character in unprotectedString) + { + secureToken.AppendChar(character); + } + secureToken.MakeReadOnly(); + + return secureToken; + } + + 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(DpapiMasterPasswordCredentialsLoader)); + } + } + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + } +} 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 @@ + + + + + + + + + + + + + + + + + + + + + + + +