diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 040d2f1..1d1eccb 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -3,12 +3,31 @@ name: CI on: [push] jobs: - build: - + build-linux: runs-on: ubuntu-latest - + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + - run: dotnet build MetasysServices --configuration Release + - run: dotnet test MetasysServicesLinux.sln + + build-macos: + runs-on: macos-latest + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "6.0.x" + - run: dotnet build MetasysServices --configuration Release + - run: dotnet test MetasysServicesLinux.sln + + build-windows: + runs-on: windows-latest + steps: - - uses: actions/checkout@master - - uses: actions/setup-dotnet@v1 - - run: dotnet build MetasysServices --configuration Release - - run: dotnet test + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + - run: dotnet build MetasysServices --configuration Release + - run: dotnet test MetasysServices.sln diff --git a/MetasysSecrets/.gitignore b/MetasysSecrets/.gitignore new file mode 100644 index 0000000..bfdbae0 --- /dev/null +++ b/MetasysSecrets/.gitignore @@ -0,0 +1 @@ +nupkg/ diff --git a/MetasysSecrets/MetasysSecrets.csproj b/MetasysSecrets/MetasysSecrets.csproj new file mode 100644 index 0000000..3341a2d --- /dev/null +++ b/MetasysSecrets/MetasysSecrets.csproj @@ -0,0 +1,28 @@ + + + + + + + + + JohnsonControls.MetasysSecrets + 1.0.0-alpha2 + Johnson Controls PLC + A cross platform cli tool for adding Metasys passwords to your operating system credential manager + secrets;secret;cli;metasys + Initial Release for macOS, Linux and Windows. The Linux version has a dependency on libsecret-tools + https://github.com/jci-metasys/basic-services-dotnet/tree/master/MetasysSecrets/ + git + BSD-3-Clause + README.md + Exe + net6.0;net8.0 + enable + enable + true + metasys-secrets + ./nupkg + + + diff --git a/MetasysSecrets/Program.cs b/MetasysSecrets/Program.cs new file mode 100644 index 0000000..ac87e5b --- /dev/null +++ b/MetasysSecrets/Program.cs @@ -0,0 +1,91 @@ +// See https://aka.ms/new-console-template for more information +using System.Runtime.InteropServices; +using System.Security; +using JohnsonControls.Metasys.BasicServices; + +if (args.Length != 3 || !(args[0] is "add" or "delete")) +{ + WriteUsage(); + return; +} + + +switch (args[0]) +{ + case "add": + var password = GetPassword(); + SecretStore.AddOrReplacePassword(args[1], args[2], password); + break; + // case "lookup": + // if (SecretStore.TryGetPassword(args[1], args[2], out SecureString securePassword)) + // { + // Console.WriteLine(ConvertToPlainText(securePassword)); + // } + // break; + case "delete": + SecretStore.DeletePassword(args[1], args[2]); + break; + default: + break; +} + +string ConvertToPlainText(SecureString secureString) +{ + IntPtr unmanagedString = IntPtr.Zero; + try + { + unmanagedString = Marshal.SecureStringToGlobalAllocUnicode(secureString); + return Marshal.PtrToStringUni(unmanagedString) ?? ""; + } + finally + { + Marshal.ZeroFreeGlobalAllocUnicode(unmanagedString); + } +} + +void WriteUsage() +{ + Console.WriteLine("Usage:"); + Console.WriteLine(" metasys-secret add {host} {username}"); + Console.WriteLine(" metasys-secret delete {host} {username}"); + return; +} + +SecureString GetPassword() +{ + SecureString password = new SecureString(); + if (Console.IsInputRedirected) + { + var input = Console.ReadLine(); + input?.ToCharArray().ToList().ForEach(password.AppendChar); + return password; + } + Console.Write("Enter your password: "); + + while (true) + { + + ConsoleKeyInfo key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + { + break; + } + else if (key.Key == ConsoleKey.Backspace) + { + if (password.Length > 0) + { + password.RemoveAt(password.Length - 1); + // backup, write a space, and back up again + Console.Write("\b \b"); + } + } + else + { + password.AppendChar(key.KeyChar); + Console.Write("*"); + } + } + Console.WriteLine(); + password.MakeReadOnly(); + return password; +} diff --git a/MetasysSecrets/README.md b/MetasysSecrets/README.md new file mode 100644 index 0000000..84caa78 --- /dev/null +++ b/MetasysSecrets/README.md @@ -0,0 +1,99 @@ +# Metasys Secrets + +A cli for adding metasys passwords to your operating system credential manager. +These credentials can then be retrieved by your applications using +[SecretStore](../MetasysServices/Credentials/Secrets.cs) and the password can be +securely passed to `MetasysClient.TryLogin` method of +[basic-services-dotnet](../README.md). + +## Installation + +You'll need a modern version of +[dotnet](https://dotnet.microsoft.com/en-us/download). Both .Net 6.0 and .Net +8.0 are supported. + +```bash +dotnet tool install --global JohnsonControls.MetasysSecrets +``` + +## Usage + +There are three subcommands `add`, `lookup` and `delete` as shown below. Each +takes a `hostName` and `userName` for arguments. + +```bash +metasys-secrets add +metasys-secrets lookup +metasys-secrets delete +``` + +### Examples + +In these examples we'll assume that the `hostName` is +`my-ads-server.company.com` and the `userName` is `api-service-account`. + +### Save a Password + +To save the password + +```bash +> metasys-secrets add my-ads-server.company.com api-service-account +Enter your password: ******* +``` + +Notice you are prompted for your password. If you really want to do it all on +one line you can do it like this. + +```bash +echo "thepassword" | metasys-secrets add my-ads-server.company.com api-service-account +``` + +> [!Warning]\ +> Be careful with this approach as your password is now shown in plain text and will +> be stored in your shell history. It's not recommended for production environments. + +### Delete a Password + +To delete a password from your credential manager you can do + +```bash +metasys-secrets delete +``` + +### Display a Password + +To read a password from the credential manager you can do + +```bash +metasys-secrets lookup +``` + +> [!Warning]\ +> Be careful with this approach as your password is now shown in plain text.It's +> not recommended for production environments. You may choose to use the GUI tool +> for your operating system instead. + +## Credential Managers + +The credential manager that is used depends on the operating system you are +using. + +### Windows + +On Windows the passwords are saved in the Windows Credential Manager. + +### macOS + +On macOS the passwords are saved in the macOS Keychain + +### Linux + +On Linux the passwords are saved using `libsecret` and it relies on the linux +command line tool `secret-tool`. To install that tool depends on the +distribution you are using. + +On Debian/Ubuntu you can install it like this + +```bash +sudo apt install libsecret-tools +``` diff --git a/MetasysServices.Tests/Credentials/CredentialsTests.cs b/MetasysServices.Tests/Credentials/CredentialsTests.cs new file mode 100644 index 0000000..febaff1 --- /dev/null +++ b/MetasysServices.Tests/Credentials/CredentialsTests.cs @@ -0,0 +1,138 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; +using NUnit.Framework; +#nullable enable + +namespace MetasysServices.Tests + +internal static class SecureStringExtensions +{ + public static string ToPlainString(this SecureString secureString) + { + + if (secureString == null) + throw new ArgumentNullException(nameof(secureString)); + + IntPtr? unmanagedString = IntPtr.Zero; + try + { + // Convert SecureString to an unmanaged string + unmanagedString = Marshal.SecureStringToGlobalAllocUnicode(secureString); + if (unmanagedString == IntPtr.Zero || unmanagedString == null) return ""; + // Convert the unmanaged string to a managed string + return Marshal.PtrToStringUni(unmanagedString.Value) ?? ""; + } + finally + { + // Free the unmanaged string + Marshal.ZeroFreeGlobalAllocUnicode(unmanagedString.Value); + } + } + + public static SecureString ToSecureString(this string insecure) + { + var chars = insecure.ToCharArray(); + var secure = new SecureString(); + chars.ToList().ForEach(secure.AppendChar); + secure.MakeReadOnly(); + return secure; + } +} +[TestFixture] +public class SecretStoreTests +{ + + [SetUp] + public void SetUp() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + if (!LinuxLibSecret.IsSecretToolAvailable()) + { + Assert.Ignore("Can run linux tests because secret-tool is not available"); + } + } + } + + [Test] + public void TestAddLookupAndDelete() + { + + var hostname = "%--HOST--NAME--%"; + var username = "service-account"; + var password = "\uD83D\uDE01Password😃"; + SecretStore.AddOrReplacePassword(hostname, username, password.ToSecureString()); + + var _ = SecretStore.TryGetPassword(hostname, username, out var password2); + + Assert.That(password2?.ToPlainString(), Is.EqualTo(password)); + + SecretStore.DeletePassword(hostname, username); + + var result = SecretStore.TryGetPassword(hostname, username, out password2); + Assert.That(result, Is.False); + } +} + + + +public class RandomStringGenerator +{ + private static readonly Random random = new Random(); + + public static string GenerateRandomString(int minLength = 10, int maxLength = 20) + { + const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + int length = random.Next(minLength, maxLength + 1); + StringBuilder result = new StringBuilder(length); + + for (int i = 0; i < length; i++) + { + result.Append(chars[random.Next(chars.Length)]); + } + + return result.ToString(); + } + + public static string GenerateRandomString2(int minLength = 10, int maxLength = 20) + { + int length = random.Next(minLength, maxLength + 1); + StringBuilder result = new StringBuilder(length); + + for (int i = 0; i < length; i++) + { + int codePoint = GetRandomCodePoint(); + result.Append(char.ConvertFromUtf32(codePoint)); + } + + return result.ToString(); + } + + private static int GetRandomCodePoint() + { + // Generate a random code point, including supplementary characters + int codePoint; + if (random.Next(0, 10) < 8) + { + // Most of the time, generate a BMP character (1-3 bytes) + codePoint = random.Next(0x0000, 0xFFFF); + } + else + { + // Occasionally generate a supplementary character (4 bytes) + codePoint = random.Next(0x10000, 0x10FFFF); + } + + // Ensure the code point is valid + while (char.GetUnicodeCategory((char)codePoint) == UnicodeCategory.OtherNotAssigned) + { + codePoint = random.Next(0x0000, 0x10FFFF); + } + + return codePoint; + } +} diff --git a/MetasysServices.Tests/HttpCallAssertionExtensions.cs b/MetasysServices.Tests/HttpCallAssertionExtensions.cs new file mode 100644 index 0000000..3077692 --- /dev/null +++ b/MetasysServices.Tests/HttpCallAssertionExtensions.cs @@ -0,0 +1,21 @@ +using Flurl.Http.Testing; + +namespace JohnsonControls.Metasys.BasicServices; + +public static class HttpCallAssertionExtensions +{ + /// + /// Asserts that the specified content was passed as a utf8 encoded byte array + /// + /// + /// + /// + public static HttpCallAssertion WithCapturedByteArrayContent(this HttpCallAssertion assertion, string expectedContent) + { + return assertion.With(call => + { + var actualContent = ((CapturedByteArrayContent)call.Request.Content).Content; + return actualContent == expectedContent; + }); + } +} diff --git a/MetasysServices.Tests/MetasysClientTests.cs b/MetasysServices.Tests/MetasysClientTests.cs index 630d923..29ecead 100644 --- a/MetasysServices.Tests/MetasysClientTests.cs +++ b/MetasysServices.Tests/MetasysClientTests.cs @@ -50,7 +50,7 @@ public async Task TestLoginAsync() httpTest.ShouldHaveCalled($"https://hostname/api/v2/login") .WithVerb(HttpMethod.Post) .WithContentType("application/json") - .WithRequestBody("{\"username\":\"username\",\"password\":\"password\"") + .WithCapturedByteArrayContent("{\"username\":\"username\",\"password\":\"password\"}") .Times(1); var token = client.GetAccessToken(); var expected = new AccessToken("hostname", "username", "Bearer faketokenLoginAsync", dateTime2); @@ -70,7 +70,7 @@ public void TestLoginAsyncContext() httpTest.ShouldHaveCalled($"https://hostname/api/v2/login") .WithVerb(HttpMethod.Post) .WithContentType("application/json") - .WithRequestBody("{\"username\":\"username\",\"password\":\"password\"") + .WithCapturedByteArrayContent("{\"username\":\"username\",\"password\":\"password\"}") .Times(1); var token = client.GetAccessToken(); var expected = new AccessToken("hostname", "username", "Bearer faketokenLoginAsyncContext", dateTime2); @@ -78,6 +78,25 @@ public void TestLoginAsyncContext() }); } + [Test] + public void TestLoginWithSecureString() + { + CleanLogin(); + httpTest.RespondWithJson(new { accessToken = "faketokenLoginSecureString", expires = date2 }); + var password = "ThePassword".ToSecureString(); + var token = client.TryLogin("api", password); + + httpTest.ShouldHaveCalled($"https://hostname/api/v2/login") + .WithVerb(HttpMethod.Post) + .WithContentType("application/json") + .WithCapturedByteArrayContent("{\"username\":\"api\",\"password\":\"ThePassword\"}") + .Times(1); + + var expected = new AccessToken("hostname", "api", "Bearer faketokenLoginSecureString", dateTime2); + Assert.AreEqual(expected, token); + + } + [Test] public void TestLoginUnauthorizedThrowsException() { @@ -91,7 +110,7 @@ public void TestLoginUnauthorizedThrowsException() httpTest.ShouldHaveCalled($"https://hostname/api/v2/login") .WithVerb(HttpMethod.Post) .WithContentType("application/json") - .WithRequestBody("{\"username\":\"username\",\"password\":\"badpassword\"") + .WithCapturedByteArrayContent("{\"username\":\"username\",\"password\":\"badpassword\"}") .Times(1); Assert.AreEqual(original, client.GetAccessToken()); // The access token is not changed on error PrintMessage($"TestLoginUnauthorizedThrowsException: {e.Message}"); @@ -111,7 +130,7 @@ public void TestLoginBadHostThrowsException() httpTest.ShouldHaveCalled($"https://badhost/api/v2/login") .WithVerb(HttpMethod.Post) .WithContentType("application/json") - .WithRequestBody("{\"username\":\"username\",\"password\":\"password\"") + .WithCapturedByteArrayContent("{\"username\":\"username\",\"password\":\"password\"}") .Times(1); Assert.AreEqual(original, client.GetAccessToken()); // The access token is not changed on error PrintMessage($"TestLoginBadHostThrowsException: {e.Message}"); @@ -130,7 +149,7 @@ public void TestLoginBadResponseMissingTokenThrowsException() httpTest.ShouldHaveCalled($"https://hostname/api/v2/login") .WithVerb(HttpMethod.Post) .WithContentType("application/json") - .WithRequestBody("{\"username\":\"username\",\"password\":\"password\"") + .WithCapturedByteArrayContent("{\"username\":\"username\",\"password\":\"password\"}") .Times(1); Assert.AreEqual(original, client.GetAccessToken()); // The access token is not changed on error PrintMessage($"TestLoginBadResponseMissingTokenThrowsException: {e.Message}"); @@ -149,7 +168,7 @@ public void TestLoginBadResponseMissingExpiresThrowsException() httpTest.ShouldHaveCalled($"https://hostname/api/v2/login") .WithVerb(HttpMethod.Post) .WithContentType("application/json") - .WithRequestBody("{\"username\":\"username\",\"password\":\"badpassword\"") + .WithCapturedByteArrayContent("{\"username\":\"username\",\"password\":\"badpassword\"}") .Times(1); Assert.AreEqual(original, client.GetAccessToken()); // The access token is not changed on error PrintMessage($"TestLoginBadResponseMissingExpiresThrowsException: {e.Message}"); @@ -2559,4 +2578,4 @@ private class TestData } #endregion } -} \ No newline at end of file +} diff --git a/MetasysServices.sln b/MetasysServices.sln index 31bc8bb..d942693 100644 --- a/MetasysServices.sln +++ b/MetasysServices.sln @@ -19,6 +19,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WeatherForecastApp", "Weath EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestClient", "TestClient\TestClient.csproj", "{D81EA69E-5B07-4797-A0BD-39F1285A4EA8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TestObjects", "TestObjects\TestObjects.csproj", "{3181C0F6-56C5-4537-B4BE-7568E1509E09}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -91,6 +93,14 @@ Global {D81EA69E-5B07-4797-A0BD-39F1285A4EA8}.Release|Any CPU.Build.0 = Release|Any CPU {D81EA69E-5B07-4797-A0BD-39F1285A4EA8}.Release|x86.ActiveCfg = Release|Any CPU {D81EA69E-5B07-4797-A0BD-39F1285A4EA8}.Release|x86.Build.0 = Release|Any CPU + {3181C0F6-56C5-4537-B4BE-7568E1509E09}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3181C0F6-56C5-4537-B4BE-7568E1509E09}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3181C0F6-56C5-4537-B4BE-7568E1509E09}.Debug|x86.ActiveCfg = Debug|Any CPU + {3181C0F6-56C5-4537-B4BE-7568E1509E09}.Debug|x86.Build.0 = Debug|Any CPU + {3181C0F6-56C5-4537-B4BE-7568E1509E09}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3181C0F6-56C5-4537-B4BE-7568E1509E09}.Release|Any CPU.Build.0 = Release|Any CPU + {3181C0F6-56C5-4537-B4BE-7568E1509E09}.Release|x86.ActiveCfg = Release|Any CPU + {3181C0F6-56C5-4537-B4BE-7568E1509E09}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MetasysServices/CapturedByteArrayContent.cs b/MetasysServices/CapturedByteArrayContent.cs new file mode 100644 index 0000000..54e42fd --- /dev/null +++ b/MetasysServices/CapturedByteArrayContent.cs @@ -0,0 +1,67 @@ +using System; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Flurl.Http.Testing; + +#nullable enable + +namespace JohnsonControls.Metasys.BasicServices; + +/// Content provided by a byte array +/// +/// Works just like a ByteArrayContent but in testing mode (HttpTest.Current isn't null) it retains the +/// contents of the request as a string for verification purposes. In production it does not do that. +/// +/// The content is assumed to be a utf8 encoded string. +/// +public class CapturedByteArrayContent : HttpContent +{ + private readonly byte[] payload; + + /// + /// Create an HttpContent instance from the specified payload. + /// + /// + public CapturedByteArrayContent(byte[] payload) + { + this.payload = payload; + if (HttpTest.Current != null) + { + Content = Encoding.UTF8.GetString(payload); + } + } + + /// + protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) + { + try + { + return stream.WriteAsync(payload, 0, payload.Length); + } + finally + { + Array.Clear(payload, 0, payload.Length); + } + + } + + /// + /// Returns the decoded byte array. + /// + /// The byte array is assumed to be utf8 encoded string. + /// This will always be null in production. + /// + public string? Content { get; private set; } + + /// + protected override bool TryComputeLength(out long length) + { + length = payload.Length; + return true; + } + + +} diff --git a/MetasysServices/Credentials/DummyStore.cs b/MetasysServices/Credentials/DummyStore.cs new file mode 100644 index 0000000..46ccc57 --- /dev/null +++ b/MetasysServices/Credentials/DummyStore.cs @@ -0,0 +1,37 @@ +using System.Security; +#nullable enable + +namespace JohnsonControls.Metasys.BasicServices; + + +/// +/// An implementation of that doesn't do anything +/// +/// +/// This is the instance of ISecretStore used by if +/// no suitable functional instance can be found. +/// +class DummyStore : ICredentialManager +{ + public override void AddOrReplacePassword(string hostName, string userName, SecureString password) + { + } + + + public override void DeletePassword(string hostName, string userName) + { + } + + public override bool TryGetCredentials(string hostName, out string userName, out SecureString password) + { + userName = string.Empty; + password = new(); + return false; + } + + public override bool TryGetPassword(string hostName, string userName, out SecureString password) + { + password = new(); + return false; + } +} diff --git a/MetasysServices/Credentials/ISecretStore.cs b/MetasysServices/Credentials/ISecretStore.cs new file mode 100644 index 0000000..7e1d1cb --- /dev/null +++ b/MetasysServices/Credentials/ISecretStore.cs @@ -0,0 +1,69 @@ +using System.Security; +#nullable enable +namespace JohnsonControls.Metasys.BasicServices; + +/// +/// Represents a secret store for passwords +/// +public abstract class ICredentialManager +{ + + /// + /// Prefix hostname before storing to avoid collisions and + /// more importantly to prevent this tool from being able to lookup + /// credentials that weren't entered by it. + /// + /// + /// + protected static string PrefixHostName(string hostName) + { + // return $"Metasys:{hostName}"; + return hostName; + } + + + /// + /// Adds or replaces a password in the secret store + /// + /// + /// The password will be recorded as being for a specific host and a specific user + /// The password can later be retrieved by calling + /// passing in the same hostName and userName that was used to save the password. + /// + /// + /// + /// + public abstract void AddOrReplacePassword(string hostName, string userName, SecureString password); + + /// + /// Attempts to retrieve a stored password + /// + /// + /// + /// + /// true if the password for the user on the specified host exists; false otherwise. + public abstract bool TryGetPassword(string hostName, string userName, out SecureString password); + + + /// + /// Attempts to retrieve username and password for specified host. + /// + /// + /// If more than one entry in the credential manager matches the host name then the first one found is returned. + /// To avoid this ambiguity use instead. + /// + /// + /// + /// + /// + public abstract bool TryGetCredentials(string hostName, out string userName, out SecureString password); + + /// + /// Deletes the password with specified hostName and userName if it exists. + /// + /// This method does nothing if the password doesn't exist + /// + /// + public abstract void DeletePassword(string hostName, string userName); + +} diff --git a/MetasysServices/Credentials/LinuxLibSecret.cs b/MetasysServices/Credentials/LinuxLibSecret.cs new file mode 100644 index 0000000..e2c7801 --- /dev/null +++ b/MetasysServices/Credentials/LinuxLibSecret.cs @@ -0,0 +1,267 @@ +using System; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; + +#nullable enable + +namespace JohnsonControls.Metasys.BasicServices; + +/// +/// Provides access to secrets stored using the Linux library libsecret. +/// +/// +/// This class has a dependency on the command line tool secret-tool which +/// can be installed on Ubuntu using sudo apt install libsecret-tools. +/// +/// The gui tool related to this is the Gnome Passwords application also known as +/// "seahorse". This tool can be used to view saved passwords but it is not useful +/// for adding passwords as it doesn't use the attributes needed for +/// to retrieve them. +/// +/// +/// If you wish to manually add passwords to the secret store. You can use secret-tool to do so. +/// Here is an example: +/// +/// secret-tool store -l "My Label" "Host Name" my-ads-server.com "User Name" apiServiceAccount +/// +/// In this example, the -l parameter allows you to pick a label for this entry. You can choose any label you wish. +/// The strings "Host Name" and "User Name" are attributes used to lookup the password later. You must enter them exactly as +/// shown. The strings my-ads-server.com and apiServiceAccount are your host name and user name respectively. +/// After running this command you'll be prompted to enter your password. +/// +/// +/// You can also pass the password on the command line but take care as it will be in plain text. To do this +/// you pipe the standard input to the secret-tool command like this: +/// +/// echo "myPlainTextPassword" | secret-tool -l "api@my-ads-server.com" "Host Name" my-ads-server.com "User Name" api +/// +/// +/// +/// If you wish to manually lookup passwords from the command line you can do so like this +/// +/// > secret-tool lookup "Host Name" my-ads-server.com "User Name" api +/// myPlainTextPassword +/// +/// +/// +public class LinuxLibSecret : ICredentialManager +{ + + private static void AssertRunningOnLinux() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || !IsSecretToolAvailable()) + { + throw new InvalidOperationException("This service can only be run on Linux and requires 'secret-tool' to be installed."); + } + } + + /// + /// Checks to see if the command line tool "secret-tool" is available + /// + /// + /// If the tool is not available it can be installed on Debian/Ubuntu systems + /// using sudo apt install libsecret-tools + /// . + /// + public static bool IsSecretToolAvailable() + { + const string toolName = "secret-tool"; + try + { + ProcessStartInfo startInfo = new ProcessStartInfo + { + FileName = toolName, + Arguments = "search \"dummy attribute\" \"dummy value\"", // or any other argument that doesn't alter state + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (Process process = Process.Start(startInfo)) + { + process.WaitForExit((int)TimeSpan.FromSeconds(2).TotalMilliseconds); + return process.ExitCode == 0; + } + } + catch + { + return false; + } + } + + /// + public override void AddOrReplacePassword(string hostName, string userName, SecureString password) + { + AssertRunningOnLinux(); + RunSecretToolStore(PrefixHostName(hostName), userName, password); + } + /// + public override bool TryGetPassword(string hostName, string userName, out SecureString password) + { + AssertRunningOnLinux(); + var result = RunSecretToolLookup(PrefixHostName(hostName), userName); + password = result == null ? new() : result; + return result != null; + } + + /// + public override void DeletePassword(string hostName, string userName) + { + AssertRunningOnLinux(); + var process = Process.Start("secret-tool", $"clear {HostNameAttribute} \"{PrefixHostName(hostName)}\" {UserNameAttribute} \"{userName}\""); + process.WaitForExit(2000); + } + + + const string HostNameAttribute = "\"Host Name\""; + const string UserNameAttribute = "\"User Name\""; + + + private static char[] SecureStringToCharArray(SecureString secureString) + { + IntPtr unmanagedString = IntPtr.Zero; + byte[] utf8Bytes; + + try + { + unmanagedString = Marshal.SecureStringToGlobalAllocUnicode(secureString); + int utf16ByteCount = secureString.Length * 2; // UTF-16 encoding uses 2 bytes per character + byte[] utf16Bytes = new byte[utf16ByteCount]; + Marshal.Copy(unmanagedString, utf16Bytes, 0, utf16ByteCount); + + utf8Bytes = Encoding.Convert(Encoding.Unicode, Encoding.UTF8, utf16Bytes); + } + finally + { + Marshal.ZeroFreeGlobalAllocUnicode(unmanagedString); + } + + char[] charArray = Encoding.UTF8.GetChars(utf8Bytes); + return charArray; + + } + + private static void RunSecretToolStore(string service, string username, SecureString password) + { + var label = $"\"Password for {username}@{service}\""; + var processStartInfo = new ProcessStartInfo + { + FileName = "secret-tool", + Arguments = $"store -l {label} {HostNameAttribute} \"{service}\" {UserNameAttribute} \"{username}\"", + RedirectStandardInput = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + char[]? chars = null; + try + { + using (var process = new Process { StartInfo = processStartInfo }) + { + process.Start(); + + chars = SecureStringToCharArray(password); + + chars.ToList().ForEach(c => process.StandardInput.Write(c)); + process.StandardInput.Close(); + + process.WaitForExit((int)TimeSpan.FromSeconds(3).TotalMilliseconds); + } + } + finally + { + if (chars != null) + { + Array.Clear(chars, 0, chars.Length); + } + } + } + + private static SecureString? RunSecretToolLookup(string service, string username) + { + var processStartInfo = new ProcessStartInfo + { + FileName = "secret-tool", + Arguments = $"lookup {HostNameAttribute} \"{service}\" {UserNameAttribute} \"{username}\"", + RedirectStandardInput = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (var process = new Process { StartInfo = processStartInfo }) + { + process.Start(); + + var password = new SecureString(); + int nextChar; + int length = 0; + while ((nextChar = process.StandardOutput.Read()) != -1) + { + length++; + password.AppendChar((char)nextChar); + } + password.MakeReadOnly(); + process.WaitForExit(); + return length > 0 ? password : null; + } + } + + /// + public override bool TryGetCredentials(string hostName, out string userName, out SecureString password) + { + return RunSecretToolLookup(hostName, out userName, out password); + } + + private bool RunSecretToolLookup(string service, out string userName, out SecureString password) + { + var processStartInfo = new ProcessStartInfo + { + FileName = "secret-tool", + Arguments = $"search {HostNameAttribute} \"{service}\"", + RedirectStandardInput = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (var process = new Process { StartInfo = processStartInfo }) + { + process.Start(); + + var output = process.StandardOutput.ReadToEnd(); + var lines = output.Split('\n'); + userName = ""; + password = new(); + bool foundUserName = false; + bool foundPassword = false; + foreach(var line in lines) + { + if (line.Contains('=')) + { + var parts = line.Split(['='], 2); + var key = parts[0].Trim(); + var value = parts[1].Trim(); + if (key == $"attribute.{UserNameAttribute}") + { + userName = value; + foundUserName = true; + } + else if (key == "secret") + { + password = new SecureString(); + value.ToCharArray().ToList().ForEach(password.AppendChar); + password.MakeReadOnly(); + foundPassword = true; + } + } + } + process.WaitForExit(); + return foundUserName && foundPassword; + } + } +} diff --git a/MetasysServices/Credentials/MacKeychain.cs b/MetasysServices/Credentials/MacKeychain.cs new file mode 100644 index 0000000..06e8bc3 --- /dev/null +++ b/MetasysServices/Credentials/MacKeychain.cs @@ -0,0 +1,312 @@ +using System.Runtime.InteropServices; +using System; +using System.Linq; +using System.Security; +using System.Diagnostics; +using System.Text.RegularExpressions; + +#nullable enable +namespace JohnsonControls.Metasys.BasicServices; + + +/// +/// An implementation of which uses the Keychain on macOS to +/// store passwords. +/// +/// +/// To manually add passwords to the Keychain, launch the Keychain application. Then select +/// File | New Password Item. In the dialog enter your host name as the "Keychain Item Name" (for +/// example, my-ads-server.my-company-com). Add your Metasys User Name in the "Account Name" field. +/// Finally type your password in the "Password" field. +/// +public class Keychain : ICredentialManager +{ + private const string SecurityLibrary = "/System/Library/Frameworks/Security.framework/Security"; + + [DllImport(SecurityLibrary)] + private static extern int SecKeychainItemDelete(IntPtr itemRef); + [DllImport(SecurityLibrary)] + private static extern int SecKeychainAddGenericPassword( + IntPtr keychain, + uint serviceNameLength, + string serviceName, + uint accountNameLength, + string accountName, + uint passwordLength, + byte[] passwordData, + IntPtr itemRef); + + [DllImport(SecurityLibrary)] + private static extern int SecKeychainFindGenericPassword( + IntPtr keychain, + uint serviceNameLength, + string serviceName, + uint accountNameLength, + string accountName, + out uint passwordLength, + out IntPtr passwordData, + out IntPtr itemRef); + + [DllImport(SecurityLibrary)] + private static extern int SecKeychainItemFreeContent( + IntPtr attrList, + IntPtr data); + + [DllImport(SecurityLibrary)] + private static extern int SecKeychainSearchCreateFromAttributes( + IntPtr keychainOrArray, + IntPtr itemClass, + IntPtr attrList, + out IntPtr searchRef + ); + + [DllImport(SecurityLibrary)] + private static extern int SecKeychainSearchCopyNext( + IntPtr searchRef, + out IntPtr itemRef + ); + + private static void AssertRunningOnMacOS() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + throw new InvalidOperationException("This service is macOS specific."); + } + } + + private void AddPassword(string hostName, string userName, SecureString password) + { + AssertRunningOnMacOS(); + var target = PrefixHostName(hostName); + + var passwordData = SecureStringToByteArray(password); + var status = SecKeychainAddGenericPassword( + IntPtr.Zero, + (uint)target.Length, + target, + (uint)userName.Length, + userName, + (uint)passwordData.Length, + passwordData, + IntPtr.Zero); + + Array.Clear(passwordData, 0, passwordData.Length); + if (status != 0) + { + throw new InvalidOperationException("Password was not saved. Perhaps a password is already saved for this account."); + } + } + + /// + public override bool TryGetPassword(string hostName, string userName, out SecureString password) + { + AssertRunningOnMacOS(); + + uint passwordLength; + IntPtr passwordData; + IntPtr itemRef; + + var target = PrefixHostName(hostName); + + var status = SecKeychainFindGenericPassword( + IntPtr.Zero, + (uint)target.Length, + target, + (uint)userName.Length, + userName, + out passwordLength, + out passwordData, + out itemRef); + + if (status == 0 && passwordData != IntPtr.Zero) + { + // char[] passwordChars = new char[(int)passwordLength / (sizeof(char))]; + // Marshal.Copy(passwordData, passwordChars, 0, (int)passwordLength / sizeof(char)); + + // Copy the password data to a byte array + byte[] passwordBytes = new byte[passwordLength]; + Marshal.Copy(passwordData, passwordBytes, 0, (int)passwordLength); + + // Convert the byte array to a string using UTF-8 encoding + char[] passwordChars = System.Text.Encoding.UTF8.GetChars(passwordBytes); + + var securePassword = new SecureString(); + passwordChars.ToList().ForEach(securePassword.AppendChar); + securePassword.MakeReadOnly(); + + Array.Clear(passwordBytes, 0, passwordBytes.Length); + Array.Clear(passwordChars, 0, passwordChars.Length); + SecKeychainItemFreeContent(IntPtr.Zero, passwordData); + password = securePassword; + return true; + } + else + { + password = new(); + return false; + } + } + + /// + public override void AddOrReplacePassword(string hostName, string userName, SecureString newPassword) + { + + AssertRunningOnMacOS(); + + var target = PrefixHostName(hostName); + + IntPtr itemRef = IntPtr.Zero; + try + { + // Find the existing password + uint passwordLength; + IntPtr passwordData; + var status = SecKeychainFindGenericPassword( + IntPtr.Zero, + (uint)target.Length, + target, + (uint)userName.Length, + userName, + out passwordLength, + out passwordData, + out itemRef); + + if (status == 0 && itemRef != IntPtr.Zero) + { + // Delete the existing password + status = SecKeychainItemDelete(itemRef); + if (status != 0) + { + throw new InvalidOperationException($"The entry for {hostName}.{userName} could not be replaced."); + } + } + + // Add the new password + AddPassword(hostName, userName, newPassword); + } + finally + { + // Free the allocated memory for the existing password + if (itemRef != IntPtr.Zero) + { + SecKeychainItemFreeContent(IntPtr.Zero, itemRef); + } + } + } + + private static void DeleteKeychainEntry(string hostName, string userName, bool exceptionIfNotFound = false) + { + AssertRunningOnMacOS(); + + IntPtr itemRef = IntPtr.Zero; + uint passwordLength; + IntPtr passwordData; + + int result = SecKeychainFindGenericPassword( + IntPtr.Zero, + (uint)hostName.Length, + hostName, + (uint)userName.Length, + userName, + out passwordLength, + out passwordData, + out itemRef + ); + + if (result == 0 && itemRef != IntPtr.Zero) + { + result = SecKeychainItemDelete(itemRef); + if (result != 0) + { + throw new InvalidOperationException($"The entry for {hostName}.{userName} could not be deleted."); + } + + } + else if (exceptionIfNotFound) + { + throw new InvalidOperationException($"The entry for {hostName}.{userName} could not be found."); + } + + if (passwordData != IntPtr.Zero) + { + Marshal.FreeHGlobal(passwordData); + } + } + + + private static byte[] SecureStringToByteArray(SecureString secureString) + { + // Convert SecureString to UTF-8 byte array + IntPtr unmanagedString = Marshal.SecureStringToGlobalAllocUnicode(secureString); + string passwordString = Marshal.PtrToStringUni(unmanagedString); + byte[] passwordBytes = System.Text.Encoding.UTF8.GetBytes(passwordString); + return passwordBytes; + } + + /// + public override void DeletePassword(string hostName, string userName) + { + var target = PrefixHostName(hostName); + DeleteKeychainEntry(target, userName); + } + + /// + public override bool TryGetCredentials(string hostname, out string username, out SecureString password) + { + string? user; + username = ""; + password = new(); + try + { + // Execute the security command to find the Keychain item + user = RunSecurityToolFindUserName(hostname); + + if (user != null) + { + username = user; + return TryGetPassword(hostname, username, out password); + } + } + finally + { + } + + return false; + } + + private static string? RunSecurityToolFindUserName(string service) + { + var processStartInfo = new ProcessStartInfo + { + FileName = "security", + Arguments = $"find-generic-password -s \"{service}\" -g", + RedirectStandardInput = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using (var process = new Process { StartInfo = processStartInfo }) + { + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + + process.WaitForExit(); + if (process.ExitCode == 0) + { + // we have no errors + return ExtractUsername(output); + } + return null; + } + } + + + private static string? ExtractUsername(string commandOutput) + { + // Updated regex to match the format "acct"="username" + Match match = Regex.Match(commandOutput, @"""acct""=""(.*?)"""); + return match.Success ? match.Groups[1].Value : null; + } + +} diff --git a/MetasysServices/Credentials/SecretStore.cs b/MetasysServices/Credentials/SecretStore.cs new file mode 100644 index 0000000..82ff1e4 --- /dev/null +++ b/MetasysServices/Credentials/SecretStore.cs @@ -0,0 +1,61 @@ +using System.Runtime.InteropServices; +using System.Security; + +#nullable enable +namespace JohnsonControls.Metasys.BasicServices +{ + + /// + /// A cross platform API for managing Metasys passwords using appropriate credential manager + /// on each operating system. + /// + public class SecretStore + { + static SecretStore() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + secretStore = new Keychain(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && LinuxLibSecret.IsSecretToolAvailable()) + { + secretStore = new LinuxLibSecret(); + } + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + secretStore = new WindowsCredentials(); + } + else + { + secretStore = new DummyStore(); + } + } + + static readonly ICredentialManager secretStore; + + /// + public static void AddOrReplacePassword(string hostName, string userName, SecureString password) + { + secretStore.AddOrReplacePassword(hostName, userName, password); + } + + /// + public static bool TryGetPassword(string hostName, string userName, out SecureString? password) + { + return secretStore.TryGetPassword(hostName, userName, out password); + } + + /// + public static bool TryGetCredentials(string hostName, out string? userName, out SecureString? password) + { + return secretStore.TryGetCredentials(hostName, out userName, out password); + } + + /// + public static void DeletePassword(string hostName, string userName) + { + secretStore.DeletePassword(hostName, userName); + } + } + +} diff --git a/MetasysServices/Credentials/WindowsCredentials.cs b/MetasysServices/Credentials/WindowsCredentials.cs new file mode 100644 index 0000000..eeb1322 --- /dev/null +++ b/MetasysServices/Credentials/WindowsCredentials.cs @@ -0,0 +1,67 @@ +using System; +using System.Runtime.InteropServices; +using System.Security; +using CredentialManagement; +#nullable enable +namespace JohnsonControls.Metasys.BasicServices; + + + + +/// +/// An implementation of that uses Windows Credential Manager to +/// save passwords. +/// +public class WindowsCredentials : ICredentialManager +{ + private static void AssertRunningOnWindows() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + throw new InvalidOperationException("This service can only be run on Linux and requires 'secret-tool' to be installed."); + } + } + + /// + public override void AddOrReplacePassword(string hostName, string userName, SecureString password) + { + AssertRunningOnWindows(); + var target = PrefixHostName(hostName); + new Credential() + { + Target = target, + Username = userName, + SecurePassword = password, + PersistanceType = PersistanceType.LocalComputer + }.Save(); + } + + + /// + public override void DeletePassword(string hostName, string userName) + { + AssertRunningOnWindows(); + var target = PrefixHostName(hostName); + var credential = new Credential { Target = target, Username = userName }; + credential.Delete(); + } + + /// + public override bool TryGetCredentials(string hostName, out string userName, out SecureString password) + { + throw new NotImplementedException(); + } + + /// + public override bool TryGetPassword(string hostName, string userName, out SecureString password) + { + AssertRunningOnWindows(); + var target = PrefixHostName(hostName); + var credential = new Credential { Target = target, Username = userName }; + var result = credential.Load(); + password = credential.SecurePassword; + return result; + + } + +} diff --git a/MetasysServices/Interfaces/IMetasysClient.cs b/MetasysServices/Interfaces/IMetasysClient.cs index edcfa1c..5a4f446 100644 --- a/MetasysServices/Interfaces/IMetasysClient.cs +++ b/MetasysServices/Interfaces/IMetasysClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Net.Http; +using System.Security; using System.Threading; using System.Threading.Tasks; @@ -119,16 +120,29 @@ public interface IMetasysClient : IBasicService /// - /// Attempts to login to the given host and retrieve an access token. + /// Attempts to login to the given host and retrieve an access token. /// + /// It's recommended that you use one of the other variants that do not + /// pass a password in plaintext. You can use to save and + /// retrieve passwords from your operating systems keystore. + /// + /// This method is deprecated. Please use one of the TryLogin methods instead. + /// + /// + /// + /// + /// + /// /// Access Token. /// /// /// Flag to set automatic access token refreshing to keep session active. /// /// + [Obsolete("Use TryLogin(string,string) instead.")] AccessToken TryLogin(string username, string password, bool refresh = true); - /// + /// + [Obsolete("Use TryLoginAsync(string,string) instead.")] Task TryLoginAsync(string username, string password, bool refresh = true); @@ -193,10 +207,10 @@ public interface IMetasysClient : IBasicService /// Read many attribute values given the Guids of the objects. /// /// - /// A list of VariantMultiple with all the specified attributes (if existing). + /// A list of VariantMultiple with all the specified attributes (if existing). /// /// - /// + /// /// /// IEnumerable ReadPropertyMultiple(IEnumerable ids, IEnumerable attributeNames); @@ -306,8 +320,8 @@ public interface IMetasysClient : IBasicService /// /// The ID of the parent object. /// The depth of the children to retrieve. - /// Set it to true to see also internal objects that are not displayed in the Metasys tree. - /// Set it to true to get also the extensions of the object. + /// Set it to true to see also internal objects that are not displayed in the Metasys tree. + /// Set it to true to get also the extensions of the object. /// The flag includeInternalObjects applies since Metasys API v3. /// /// @@ -321,7 +335,7 @@ public interface IMetasysClient : IBasicService /// /// The object type enum set. /// - /// + /// IEnumerable GetObjects(Guid objectId, string objectType); /// @@ -407,16 +421,80 @@ public interface IMetasysClient : IBasicService #endregion /// - /// Attempts to login to the given host using Credential Manager and retrieve an access token. + /// Attempts to login to the given host using Credential Manager and retrieve an access token. /// + /// + /// This method is deprecated. Use or + /// instead. + /// /// The Credential Manager target where to pick the credentials. /// Flag to set automatic access token refreshing to keep session active. /// This method can be overridden by extended class with other Credential Manager implementations. + [Obsolete("Use TryLogin() instead.")] AccessToken TryLogin(string credManTarget, bool refresh = true); - /// + /// + [Obsolete("Use TryLoginAsync() instead.")] Task TryLoginAsync(string credManTarget, bool refresh = true); + + /// + /// Attempts to connect to the configured Metasys hostname. + /// + /// + /// This method attempts to locate credentials for the specified host name in a credential manager and + /// if it finds credentials it attempts to login to Metasys using those credentials. + /// + /// The supported credential managers are Credential Manager for Windows, Keychain for macOS and + /// libsecret (Gnome passwords) for Linux. + /// + /// + public AccessToken TryLogin(); + /// + public Task TryLoginAsync(); + + /// + /// Attempts to connect to the configured Metasys hostname + /// + /// + /// This method attempts to locate a password for the specified host name and user name in a credential manager + /// and if it finds the password attempts to login to Metasys. + /// + /// The supported credential managers are Credential Manager for Windows, Keychain for macOS and + /// libsecret (Gnome passwords) for Linux. + /// + /// + /// + public AccessToken TryLogin(string username); + /// + public Task TryLoginAsync(string username); + + + /// + /// Attempts to connect to Metasys using specified user name and password. + /// + /// + /// + public AccessToken TryLogin(string username, SecureString password); + /// + public Task TryLoginAsync(string username, SecureString password); + + /// + /// Attempts to connect to Metasys using specified user name and password + /// + /// + /// This method is not recommended for production sites. Please use + /// or + /// or instead. + /// + /// + /// + public AccessToken TryLogin(string username, string password); + /// + public Task TryLoginAsync(string username, string password); + + + /// /// Returns the current server time in UTC format. /// @@ -429,7 +507,7 @@ public interface IMetasysClient : IBasicService /// /// Send an HTTP request as an asynchronous operation. - /// + /// /// /// This method currently only supports 1 value per header rather than multiple. In a future revision, this is planned to be addressed. /// diff --git a/MetasysServices/MetasysClient.cs b/MetasysServices/MetasysClient.cs index 77e1d60..2c7ba51 100644 --- a/MetasysServices/MetasysClient.cs +++ b/MetasysServices/MetasysClient.cs @@ -5,10 +5,10 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Net; using System.Net.Http; +using System.Security; using System.Threading; using System.Threading.Tasks; using System.Timers; @@ -253,37 +253,108 @@ public MetasysClient(string hostname, bool ignoreCertificateErrors = false, ApiV #region "LOGIN" // ========================================================================================================== // TryLogin ------------------------------------------------------------------------------------------------------------------------------------- /// + [Obsolete("Use TryLogin(string,SecureString) instead.")] public AccessToken TryLogin(string username, string password, bool refresh = true) { return TryLoginAsync(username, password, true).GetAwaiter().GetResult(); } /// - public async Task TryLoginAsync(string username, string password, bool refresh = true) + [Obsolete("Use TryLoginAsync(string,SecureString) instead.")] + public Task TryLoginAsync(string username, string password, bool refresh = true) { + var securePassword = new SecureString(); + password.ToCharArray().ToList().ForEach(securePassword.AppendChar); + return TryLoginAsync(username, securePassword); + } + + /// + public AccessToken TryLogin() + { + return TryLoginAsync().GetAwaiter().GetResult(); + } + + + /// + public Task TryLoginAsync() + { + if (SecretStore.TryGetCredentials(hostname, out string username, out SecureString password)) + { + return TryLoginAsync(username, password); + } + throw new CredManException($"Cannot locate credentials for {hostname}"); + + } + + /// + public AccessToken TryLogin(string username) + { + return TryLoginAsync(username).GetAwaiter().GetResult(); + } + + /// + public Task TryLoginAsync(string username) + { + if (SecretStore.TryGetPassword(hostname, username, out SecureString password)) + { + return TryLoginAsync(username, password); + } + throw new CredManException($"Cannot locate credentials for {hostname}"); + } + + /// + public AccessToken TryLogin(string username, string password) + { + return TryLoginAsync(username, password).GetAwaiter().GetResult(); + } + + /// + public Task TryLoginAsync(string username, string password) + { + var securePassword = new SecureString(); + password.ToCharArray().ToList().ForEach(securePassword.AppendChar); + securePassword.MakeReadOnly(); + return TryLoginAsync(username, securePassword); + } + + /// + public AccessToken TryLogin(string username, SecureString password) + { + return TryLoginAsync(username, password).GetAwaiter().GetResult(); + } + /// + public async Task TryLoginAsync(string username, SecureString password) + { + try { + // The using declaration ensures Dispose is called on credentials to clear password chars from memory + using var credentials = new MetasysCredentials(username, password); + var response = await Client.Request("login") - .PostJsonAsync(new { username, password }) + .WithHeader("Content-Type", "application/json") + .PostAsync(new CapturedByteArrayContent(credentials.EncodedPayload)) .ReceiveJson() .ConfigureAwait(false); - this.RefreshToken = true; - + RefreshToken = true; CreateAccessToken(Hostname, username, response); + if (Streams != null) { - Streams.AccessToken = this.AccessToken; ; + Streams.AccessToken = AccessToken; ; } } catch (FlurlHttpException e) { ThrowHttpException(e); } - return this.AccessToken; + return AccessToken; + } // TryLogin (2) ------------------------------------------------------------------------------------------------------------- /// + [Obsolete("Use TryLogin(string) instead.")] public virtual AccessToken TryLogin(string credManTarget, bool refresh = true) { return TryLoginAsync(credManTarget, true).GetAwaiter().GetResult(); @@ -294,7 +365,7 @@ public virtual async Task TryLoginAsync(string credManTarget, bool // Retrieve credentials first var credentials = CredentialUtil.GetCredential(credManTarget); // Get the control back to TryLogin method - return await TryLoginAsync(CredentialUtil.convertToUnSecureString(credentials.Username), CredentialUtil.convertToUnSecureString(credentials.Password), true).ConfigureAwait(false); + return await TryLoginAsync(CredentialUtil.convertToUnSecureString(credentials.Username), credentials.Password).ConfigureAwait(false); } // GetAccessToken ----------------------------------------------------------------------------------------------------------- @@ -325,7 +396,7 @@ public async Task RefreshAsync() var response = await Client.Request("refreshToken") .GetJsonAsync() .ConfigureAwait(false); - // Since it's a refresh, get issue info from the current token + // Since it's a refresh, get issue info from the current token CreateAccessToken(AccessToken.Issuer, AccessToken.IssuedTo, response); // Set the new value of the Token to the StreamClient if (Streams != null) @@ -564,7 +635,7 @@ public async Task GetObjectIdentifierAsync(string itemReference) { // Sanitize given itemReference var normalizedItemReference = itemReference.Trim().ToUpper(); - // Returns cached value when available, otherwise perform request + // Returns cached value when available, otherwise perform request if (!IdentifiersDictionary.ContainsKey(normalizedItemReference)) { JToken response = null; @@ -957,7 +1028,7 @@ private void ScheduleRefresh() //{ // DateTime now = DateTime.UtcNow; // TimeSpan delay = AccessToken.Expires - now.AddSeconds(-1); // minimum renew gap of 1 sec in advance - // // Renew one minute before expiration if there is more than one minute time + // // Renew one minute before expiration if there is more than one minute time // if (delay > new TimeSpan(0, 1, 0)) // { // delay.Subtract(new TimeSpan(0, 1, 0)); @@ -982,7 +1053,7 @@ private void ScheduleRefresh() /// - /// Overload of ReadPropertyAsync for internal use where Exception suppress is needed, e.g. ReadPropertyMultiple + /// Overload of ReadPropertyAsync for internal use where Exception suppress is needed, e.g. ReadPropertyMultiple /// /// /// @@ -1003,7 +1074,7 @@ private async Task ReadPropertyAsync(Guid id, string attributeName, boo /// /// Creates the body for the WriteProperty and WritePropertyMultiple requests as a dictionary. /// - /// The (attribute, value) pairs. + /// The (attribute, value) pairs. /// Dictionary of the attribute, value pairs. private Dictionary GetWritePropertyBody(IEnumerable<(string Attribute, object Value)> attributeValues) { @@ -1040,7 +1111,7 @@ private async Task WritePropertyRequestAsync(Guid id, Dictionary } ///// - ///// Gets the type from a token retrieved from a typeUrl + ///// Gets the type from a token retrieved from a typeUrl ///// ///// ///// @@ -1144,7 +1215,7 @@ private IEnumerable ToVariantMultiples(JToken response) var m = multiples.SingleOrDefault(s => s.Id == objId); if (m == null) { - // Add a new multiple for the current object + // Add a new multiple for the current object multiples.Add(new VariantMultiple(objId, values)); } else @@ -1237,8 +1308,8 @@ private Url GetUrlFromHttpRequest(HttpRequestMessage requestMessage) return new Url(Url.Combine(baseUri.GetLeftPart(UriPartial.Authority), "/api/", requestUri)); } } + #endregion } } - diff --git a/MetasysServices/MetasysCredentials.cs b/MetasysServices/MetasysCredentials.cs new file mode 100644 index 0000000..9fd9181 --- /dev/null +++ b/MetasysServices/MetasysCredentials.cs @@ -0,0 +1,132 @@ +using System; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; + +#nullable enable + +namespace JohnsonControls.Metasys.BasicServices; + +/// +/// A class to hold username and password (in SecureString) and which +/// provides an property to use to login +/// to REST API /login +/// +/// +/// This class exists so that the password never needs to be converted to a +/// string which represents a security vulnerability since you can't explicitly +/// clear memory associated with a string. +/// +/// You must ensure Dispose is called on this class as soon as you are done +/// with the `EncodedPayload` to clear the password bytes from memory. Best to +/// just wrap it in a using statement. +/// +/// +public class MetasysCredentials : IDisposable +{ + + /// + /// Create a MetasysCredentials Login request payload. + /// + /// + /// + public MetasysCredentials(string username, SecureString password) + { + this.username = username; + this.password = password; + } + private readonly string username; + private readonly SecureString password; + + + private byte[]? encodedPayload; + + /// + /// Retrieve the Utf8 encoded payload to be used in the login request. + /// + public byte[] EncodedPayload + { + get + { + if (encodedPayload == null) + { + encodedPayload = EncodePayload(username, password); + } + return encodedPayload; + } + } + + private static byte[] SecureStringToUtf8Bytes(SecureString secureString) + { + IntPtr bstr = Marshal.SecureStringToBSTR(secureString); + int length = secureString.Length; + char[] chars = new char[length]; + byte[] utf8Bytes; + + try + { + Marshal.Copy(bstr, chars, 0, length); + utf8Bytes = Encoding.UTF8.GetBytes(chars); + } + finally + { + Array.Clear(chars, 0, length); + Marshal.ZeroFreeBSTR(bstr); + } + + return utf8Bytes; + } + + + /// + /// Encode this into a the login request payload + /// + /// + /// This method was created to avoid every having the password in a String + /// + /// + /// + public byte[] EncodePayload(string username, SecureString password) + { + IntPtr passwordPtr = IntPtr.Zero; + byte[] passwordBytes = SecureStringToUtf8Bytes(password); + try + { + // Construct JSON payload + string jsonTemplate = @$"{"{"}""username"":""{username}"",""password"":"""; + + //string jsonTemplate = "\"username\":\"{0}\",\"password\":\""; + byte[] jsonPrefix = Encoding.UTF8.GetBytes(jsonTemplate); + byte[] jsonSuffix = Encoding.UTF8.GetBytes("\"}"); + + byte[] payload = new byte[jsonPrefix.Length + passwordBytes.Length + jsonSuffix.Length]; + Buffer.BlockCopy(jsonPrefix, 0, payload, 0, jsonPrefix.Length); + Buffer.BlockCopy(passwordBytes, 0, payload, jsonPrefix.Length, passwordBytes.Length); + Buffer.BlockCopy(jsonSuffix, 0, payload, jsonPrefix.Length + passwordBytes.Length, jsonSuffix.Length); + + return payload; + } + catch (Exception ex) + { + Console.WriteLine(ex); + throw; + } + finally + { + if (passwordPtr != IntPtr.Zero) + { + Marshal.ZeroFreeGlobalAllocUnicode(passwordPtr); + } + if (passwordBytes != null) + { + Array.Clear(passwordBytes, 0, passwordBytes.Length); + } + } + } + + /// + public void Dispose() + { + if (encodedPayload != null) Array.Clear(encodedPayload, 0, encodedPayload.Length); + } +} diff --git a/MetasysServices/MetasysServices.csproj b/MetasysServices/MetasysServices.csproj index d4c9356..fe6502b 100644 --- a/MetasysServices/MetasysServices.csproj +++ b/MetasysServices/MetasysServices.csproj @@ -1,9 +1,9 @@ - + netstandard2.0 true JohnsonControls.Metasys.BasicServices - 7.3 + 12 true © 2020-2024 Johnson Controls Johnson Controls Int. @@ -17,8 +17,8 @@ - Updated the dictionaries of all the supported locales - Cleaned up unnecessary code LICENSE - README.md - 6.0.4 + README.md + 6.0.4 @@ -27,7 +27,7 @@ - 0 + 4 @@ -48,7 +48,7 @@ True - + True @@ -67,7 +67,7 @@ README.md - + diff --git a/MetasysServicesCom/LegacyMetasysClient.cs b/MetasysServicesCom/LegacyMetasysClient.cs index 1f27876..d21c0ba 100644 --- a/MetasysServicesCom/LegacyMetasysClient.cs +++ b/MetasysServicesCom/LegacyMetasysClient.cs @@ -88,7 +88,7 @@ internal LegacyMetasysClient(IMetasysClient client) /// public IComAccessToken TryLogin(string username, string password, bool refresh = true) { - return Mapper.Map(Client.TryLogin(username, password, refresh)); + return Mapper.Map(Client.TryLogin(username, password)); } /// public string TryLogin2(string username, string password, bool refresh = true) @@ -96,7 +96,7 @@ public string TryLogin2(string username, string password, bool refresh = true) string res; try { - AccessToken accToken = Client.TryLogin(username, password, refresh); + AccessToken accToken = Client.TryLogin(username, password); res = accToken.Token.ToString(); } catch (Exception ex) @@ -111,7 +111,7 @@ public string TryLogin2(string username, string password, bool refresh = true) /// public IComAccessToken TryLoginWithCredMan(string target, bool refresh = true) { - return Mapper.Map(Client.TryLogin(target, refresh)); + return Mapper.Map(Client.TryLogin(target)); } // GetAccessToken ----------------------------------------------------------------------------------------------------------- @@ -938,4 +938,4 @@ public void KeepStreamAlive() #endregion } -} \ No newline at end of file +} diff --git a/MetasysServicesExampleApp/FeaturesDemo/JsonOutputDemo.cs b/MetasysServicesExampleApp/FeaturesDemo/JsonOutputDemo.cs index 563ee18..1febd82 100644 --- a/MetasysServicesExampleApp/FeaturesDemo/JsonOutputDemo.cs +++ b/MetasysServicesExampleApp/FeaturesDemo/JsonOutputDemo.cs @@ -87,15 +87,11 @@ private void TryLogin_Refresh() /* SNIPPET 1: START */ // Automatically refresh token using plain credentials client.TryLogin("username", "password"); - // Do not automatically refresh token using plain credentials - client.TryLogin("username", "password", false); /* SNIPPET 1: END */ /* SNIPPET 2: START */ // Read target from Credential Manager and automatically refresh token client.TryLogin("metasys-energy-app"); - // Read target from Credential Manager and do not refresh token - client.TryLogin("metasys-energy-app", false); /* SNIPPET 2: END */ /* SNIPPET 3: START */ diff --git a/MetasysServicesLinux.sln b/MetasysServicesLinux.sln new file mode 100644 index 0000000..8aff1c6 --- /dev/null +++ b/MetasysServicesLinux.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MetasysServices", "MetasysServices\MetasysServices.csproj", "{FF8EBF3D-72FA-46F0-AF8D-D4C08A6F4D40}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetasysServices.Tests", "MetasysServices.Tests\MetasysServices.Tests.csproj", "{10132A87-B9FB-40D1-8306-4D21764DEE32}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MetasysSecrets", "MetasysSecrets\MetasysSecrets.csproj", "{A2982304-552E-4447-BD0D-DD810CFA57BE}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FF8EBF3D-72FA-46F0-AF8D-D4C08A6F4D40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF8EBF3D-72FA-46F0-AF8D-D4C08A6F4D40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF8EBF3D-72FA-46F0-AF8D-D4C08A6F4D40}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF8EBF3D-72FA-46F0-AF8D-D4C08A6F4D40}.Release|Any CPU.Build.0 = Release|Any CPU + {10132A87-B9FB-40D1-8306-4D21764DEE32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10132A87-B9FB-40D1-8306-4D21764DEE32}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10132A87-B9FB-40D1-8306-4D21764DEE32}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10132A87-B9FB-40D1-8306-4D21764DEE32}.Release|Any CPU.Build.0 = Release|Any CPU + {A2982304-552E-4447-BD0D-DD810CFA57BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2982304-552E-4447-BD0D-DD810CFA57BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2982304-552E-4447-BD0D-DD810CFA57BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2982304-552E-4447-BD0D-DD810CFA57BE}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {20BCA9A9-3394-4960-BED6-6C6907935C76} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index b683780..5cf13a6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# Metasys Basic Services ![Nuget](https://img.shields.io/nuget/v/JohnsonControls.Metasys.BasicServices) + +# Metasys Basic Services ![Nuget](https://img.shields.io/nuget/v/JohnsonControls.Metasys.BasicServices) This project provides a library for accessing the most common services of the Metasys Server API. The intent is to provide an API that is very similar to the original MSSDA @@ -18,7 +19,7 @@ For versioning information see the [changelog](CHANGELOG.md). - [Login and Access Tokens](#login-and-access-tokens) - [Localization of Metasys Enumerations](#localization-of-metasys-enumerations) - [Metasys Objects](#metasys-objects) - - [Get Object Id](#Get-object-Id) + - [Get Object Id](#get-object-id) - [Get a Property](#get-a-property) - [Write a Property](#write-a-property) - [Get and Send Commands](#get-and-send-commands) @@ -53,7 +54,7 @@ For versioning information see the [changelog](CHANGELOG.md). - [Get Alarms for an Object](#get-alarms-for-an-object) - [Get Alarms for a Network Device](#get-alarms-for-a-network-device) - [Get Alarm Annotations](#get-alarm-annotations) - - [Acknowlege an Alarm](#acknowledge-an-alarm) + - [Acknowledge an Alarm](#acknowledge-an-alarm) - [Discard an Alarm](#discard-an-alarm) - [Audits](#audits) - [Get Audits](#get-audits) @@ -76,20 +77,20 @@ For versioning information see the [changelog](CHANGELOG.md). - [Edit a Custom Enumeration](#edit-a-custom-enumeration) - [Replace a Custom Enumeration](#replace-a-custom-enumeration) - [Delete a Custom Enumeration](#delete-a-custom-enumeration) - - [Streams](#Streams) + - [Streams](#streams) - [Reading Object PresentValue COV](#reading-object-presentvalue-cov) - [Collecting Alarm Events](#collecting-alarm-events) - [Collecting Audit Events](#collecting-audit-events) - [Keep the Stream Alive](#keep-the-stream-alive) - ['Ad-Hoc' call](#ad-hoc-call) - [SendAsync](#sendasync) - - [Activities](#Activities) - - [Get Activities](#get-activities) + - [Activities](#activities-1) + - [Get Activities](#get-activities-1) - [Multiple Actions](#multiple-actions) - [Usage (COM)](#usage-com) - [Creating a Client](#creating-a-client-1) - [Login and Access Tokens](#login-and-access-tokens-1) - - [Metasys Objects](#metasys-objects) + - [Metasys Objects](#metasys-objects-1) - [Get Object Id](#get-object-id-1) - [Get a Property](#get-a-property-1) - [Write a Property](#write-a-property-1) @@ -123,7 +124,7 @@ For versioning information see the [changelog](CHANGELOG.md). - [Get Alarms for an Object](#get-alarms-for-an-object-1) - [Get Alarms for a Network Device](#get-alarms-for-a-network-device-1) - [Get Alarm Annotations](#get-alarm-annotations-1) - - [Acknowlege an Alarm](#acknowledge-an-alarm-1) + - [Acknowledge an Alarm](#acknowledge-an-alarm-1) - [Discard an Alarm](#discard-an-alarm-1) - [Audits](#audits-1) - [Get Audits](#get-audits-1) @@ -146,7 +147,7 @@ For versioning information see the [changelog](CHANGELOG.md). - [Edit a Custom Enumeration](#edit-a-custom-enumeration-1) - [Replace a Custom Enumeration](#replace-a-custom-enumeration-1) - [Delete a Custom Enumeration](#delete-a-custom-enumeration-1) - - [Streams](#Streams-1) + - [Streams](#streams-1) - [Reading Object PresentValue COV](#reading-object-presentvalue-cov-1) - [Collecting Alarm Events](#collecting-alarm-events-1) - [Collecting Audit Events](#collecting-audit-events-1) @@ -280,9 +281,15 @@ client.Hostname = "WIN2016-VM2"; ### Login and Access Tokens After creating the client, to login use the method **`TryLogin`**. -The signature has two overloads: the first uses the Credential Manager target to read the credentials, whilst the second takes a username and password. -Both signatures take an optional parameter to automatically refresh the access token during the client's lifetime. -The default token refresh policy is true. See more information [here](https://support.microsoft.com/en-us/help/4026814/windows-accessing-credential-manager) on how to use Credential Manager. If something goes wrong while accessing a Credential Manager target, MetasysClient raises a CredManException. Keep in mind that Credential Manager is available on Windows and is not going to work on other platforms. However, MetasysClient Class could be extended by developers to implement different secure vaults support. +The signature has several overloads: + +- the first uses the Credential Manager target to read the credentials, +- another takes a username and password with the password passed as a `SecureString` +- the last one takes a username and password in plain text (the one is not recommended as it leaves the password in clear text). + +All of them refresh the access token during the client's lifetime. + +See more information [here](https://support.microsoft.com/en-us/help/4026814/windows-accessing-credential-manager) on how to use Credential Manager. If something goes wrong while accessing a Credential Manager target, MetasysClient raises a CredManException. Keep in mind that Credential Manager is available on Windows and is not going to work on other platforms. However, MetasysClient Class could be extended by developers to implement different secure vaults support. **Notice: when developing an application that uses a system account always logged without user input, the preferred way to login is to store the username and password in the Credential Manager vault.** @@ -2289,5 +2296,3 @@ See [CONTRIBUTING](CONTRIBUTING). - zh-TW ### Customizing Windows IIS for Metasys API To get further information about customizing Windows IIS for Metasys API click [here](https://docs.johnsoncontrols.com/bas/r/Metasys-Server/11.0/Metasys-Server-Installation-and-Upgrade-Instructions/Customizing-Windows-IIS-for-Metasys-API) - -