From 6dc61d5fdae2722a95e0ff5e9e8762e9a3ea87ee Mon Sep 17 00:00:00 2001 From: netoyou <2671501@qq.com> Date: Wed, 26 Aug 2026 20:33:42 +0800 Subject: [PATCH 01/16] Add Windows CI and WPF management client --- .github/workflows/windows-build.yml | 94 ++++ MultiOtpManager/App.config | 6 + MultiOtpManager/App.xaml | 5 + MultiOtpManager/App.xaml.cs | 8 + .../Core/CredentialProviderRegistryService.cs | 127 +++++ MultiOtpManager/Core/MultiOtpCliClient.cs | 75 +++ .../Core/MultiOtpProcessExecutor.cs | 262 ++++++++++ MultiOtpManager/Core/UserModels.cs | 138 ++++++ MultiOtpManager/MainWindow.xaml | 465 ++++++++++++++++++ MultiOtpManager/MainWindow.xaml.cs | 427 ++++++++++++++++ MultiOtpManager/MultiOtpManager.csproj | 73 +++ MultiOtpManager/Properties/AssemblyInfo.cs | 10 + MultiOtpManager/README.md | 28 ++ MultiOtpManager/app.manifest | 23 + 14 files changed, 1741 insertions(+) create mode 100644 .github/workflows/windows-build.yml create mode 100644 MultiOtpManager/App.config create mode 100644 MultiOtpManager/App.xaml create mode 100644 MultiOtpManager/App.xaml.cs create mode 100644 MultiOtpManager/Core/CredentialProviderRegistryService.cs create mode 100644 MultiOtpManager/Core/MultiOtpCliClient.cs create mode 100644 MultiOtpManager/Core/MultiOtpProcessExecutor.cs create mode 100644 MultiOtpManager/Core/UserModels.cs create mode 100644 MultiOtpManager/MainWindow.xaml create mode 100644 MultiOtpManager/MainWindow.xaml.cs create mode 100644 MultiOtpManager/MultiOtpManager.csproj create mode 100644 MultiOtpManager/Properties/AssemblyInfo.cs create mode 100644 MultiOtpManager/README.md create mode 100644 MultiOtpManager/app.manifest diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..027a177 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,94 @@ +name: Windows build + +on: + push: + branches: + - master + - main + pull_request: + workflow_dispatch: + +env: + BUILD_CONFIGURATION: Release + +jobs: + credential-provider: + name: Credential Provider (${{ matrix.artifactName }}) + runs-on: windows-2022 + timeout-minutes: 60 + + strategy: + fail-fast: false + matrix: + include: + - platform: x64 + artifactName: x64 + - platform: Win32 + artifactName: x86 + + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Locate MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Build solution + shell: pwsh + run: > + msbuild multiOTPCredentialProvider.sln + /m + /nologo + /p:Configuration=${{ env.BUILD_CONFIGURATION }} + /p:Platform=${{ matrix.platform }} + /p:PlatformToolset=v143 + /bl:${{ github.workspace }}\msbuild-${{ matrix.artifactName }}.binlog + + - name: Upload ${{ matrix.artifactName }} artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: multiotp-credential-provider-${{ matrix.artifactName }} + if-no-files-found: error + path: | + CredentialProvider/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.dll + CredentialProviderFilter/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.dll + CppClientCore/CppClientCore/${{ matrix.platform }}/${{ env.BUILD_CONFIGURATION }}/*.lib + WixInstall/multiOTPCredentialProviderInstaller/bin/${{ matrix.artifactName }}/${{ env.BUILD_CONFIGURATION }}/*.msi + msbuild-${{ matrix.artifactName }}.binlog + + wpf-manager: + name: WPF manager + runs-on: windows-2022 + timeout-minutes: 30 + + steps: + - name: Check out sources + uses: actions/checkout@v4 + + - name: Locate MSBuild + uses: microsoft/setup-msbuild@v2 + + - name: Install .NET Framework 4.5.2 Developer Pack + shell: pwsh + run: choco install netfx-4.5.2-devpack --no-progress -y + + - name: Build manager + shell: pwsh + run: > + msbuild MultiOtpManager/MultiOtpManager.csproj + /m + /nologo + /p:Configuration=${{ env.BUILD_CONFIGURATION }} + /p:Platform=AnyCPU + /bl:${{ github.workspace }}\msbuild-manager.binlog + + - name: Upload manager artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: multiotp-manager + if-no-files-found: error + path: | + MultiOtpManager/bin/${{ env.BUILD_CONFIGURATION }}/* + msbuild-manager.binlog diff --git a/MultiOtpManager/App.config b/MultiOtpManager/App.config new file mode 100644 index 0000000..c845ae4 --- /dev/null +++ b/MultiOtpManager/App.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/MultiOtpManager/App.xaml b/MultiOtpManager/App.xaml new file mode 100644 index 0000000..8e52a96 --- /dev/null +++ b/MultiOtpManager/App.xaml @@ -0,0 +1,5 @@ + + diff --git a/MultiOtpManager/App.xaml.cs b/MultiOtpManager/App.xaml.cs new file mode 100644 index 0000000..7cdd324 --- /dev/null +++ b/MultiOtpManager/App.xaml.cs @@ -0,0 +1,8 @@ +using System.Windows; + +namespace MultiOtpManager +{ + public partial class App : Application + { + } +} diff --git a/MultiOtpManager/Core/CredentialProviderRegistryService.cs b/MultiOtpManager/Core/CredentialProviderRegistryService.cs new file mode 100644 index 0000000..da2062f --- /dev/null +++ b/MultiOtpManager/Core/CredentialProviderRegistryService.cs @@ -0,0 +1,127 @@ +using Microsoft.Win32; +using System; + +namespace MultiOtpManager.Core +{ + public sealed class CredentialProviderSettings + { + public string LogonMode { get; set; } + public string UnlockMode { get; set; } + public bool TwoStepHideOtp { get; set; } + } + + public sealed class CredentialProviderRegistryService + { + private const string RegistryPath = "CLSID\\{FCEFDFAB-B0A1-4C4D-8B2B-4FF4E0A3D978}"; + + public CredentialProviderSettings Load() + { + using (RegistryKey classesRoot = OpenClassesRoot()) + using (RegistryKey key = classesRoot.OpenSubKey(RegistryPath)) + { + if (key == null) + { + return new CredentialProviderSettings + { + LogonMode = "3d", + UnlockMode = "3d", + TwoStepHideOtp = false + }; + } + + return new CredentialProviderSettings + { + LogonMode = ReadString(key, "cpus_logon", "3d"), + UnlockMode = ReadString(key, "cpus_unlock", "3d"), + TwoStepHideOtp = ReadInteger(key, "two_step_hide_otp", 0) != 0 + }; + } + } + + public void Save(CredentialProviderSettings settings) + { + if (settings == null) + { + throw new ArgumentNullException("settings"); + } + + string logonMode = NormalizeScenario(settings.LogonMode, "3d"); + string unlockMode = NormalizeScenario(settings.UnlockMode, "3d"); + + using (RegistryKey classesRoot = OpenClassesRoot()) + using (RegistryKey key = classesRoot.CreateSubKey(RegistryPath)) + { + key.SetValue("cpus_logon", logonMode, RegistryValueKind.String); + key.SetValue("cpus_unlock", unlockMode, RegistryValueKind.String); + key.SetValue("two_step_hide_otp", settings.TwoStepHideOtp ? "1" : "0", RegistryValueKind.String); + } + } + + private static RegistryKey OpenClassesRoot() + { + RegistryView view = Environment.Is64BitOperatingSystem ? RegistryView.Registry64 : RegistryView.Default; + return RegistryKey.OpenBaseKey(RegistryHive.ClassesRoot, view); + } + + private static string ReadString(RegistryKey key, string name, string fallback) + { + object value = key.GetValue(name); + if (value == null) + { + return fallback; + } + + string text = Convert.ToString(value); + return string.IsNullOrWhiteSpace(text) ? fallback : text; + } + + private static int ReadInteger(RegistryKey key, string name, int fallback) + { + object value = key.GetValue(name); + if (value == null) + { + return fallback; + } + + try + { + return Convert.ToInt32(value); + } + catch (FormatException) + { + return fallback; + } + catch (InvalidCastException) + { + return fallback; + } + catch (OverflowException) + { + return fallback; + } + } + + private static string NormalizeScenario(string value, string fallback) + { + string text = (value ?? string.Empty).Trim(); + if (text.Length == 0) + { + text = fallback; + } + + char scope = text[0]; + if (scope != '0' && scope != '1' && scope != '2' && scope != '3') + { + scope = fallback[0]; + } + + char availability = text.Length > 1 ? char.ToLowerInvariant(text[text.Length - 1]) : 'd'; + if (availability != 'e' && availability != 'd') + { + availability = 'd'; + } + + return string.Concat(scope.ToString(), availability.ToString()); + } + } +} diff --git a/MultiOtpManager/Core/MultiOtpCliClient.cs b/MultiOtpManager/Core/MultiOtpCliClient.cs new file mode 100644 index 0000000..93470d0 --- /dev/null +++ b/MultiOtpManager/Core/MultiOtpCliClient.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace MultiOtpManager.Core +{ + public sealed class MultiOtpCliClient + { + private readonly MultiOtpProcessExecutor executor; + + public MultiOtpCliClient() + { + string baseDirectory = AppDomain.CurrentDomain.BaseDirectory; + string executablePath = Path.Combine(baseDirectory, "multiotp.exe"); + executor = new MultiOtpProcessExecutor(executablePath); + } + + public string ExecutablePath + { + get { return executor.ExecutablePath; } + } + + public bool UseVerifySwitch { get; set; } + + public Task VerifyAsync( + string username, + string otp, + TimeSpan timeout, + CancellationToken cancellationToken) + { + ValidateRequiredText(username, "username"); + ValidateRequiredText(otp, "OTP"); + + List arguments = new List(); + if (UseVerifySwitch) + { + arguments.Add("-verify"); + } + + arguments.Add(username); + arguments.Add(otp); + return executor.ExecuteAsync(arguments, timeout, cancellationToken); + } + + public Task GetUsersAsync(TimeSpan timeout, CancellationToken cancellationToken) + { + List arguments = new List(); + arguments.Add("-userslist"); + return executor.ExecuteAsync(arguments, timeout, cancellationToken); + } + + public Task GetUserAsync( + string username, + TimeSpan timeout, + CancellationToken cancellationToken) + { + ValidateRequiredText(username, "username"); + + List arguments = new List(); + arguments.Add("-user-info"); + arguments.Add(username); + return executor.ExecuteAsync(arguments, timeout, cancellationToken); + } + + private static void ValidateRequiredText(string value, string fieldName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException(fieldName + " is required.", fieldName); + } + } + } +} diff --git a/MultiOtpManager/Core/MultiOtpProcessExecutor.cs b/MultiOtpManager/Core/MultiOtpProcessExecutor.cs new file mode 100644 index 0000000..c9d4ff3 --- /dev/null +++ b/MultiOtpManager/Core/MultiOtpProcessExecutor.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace MultiOtpManager.Core +{ + public sealed class ProcessRunResult + { + public int ExitCode { get; set; } + public string StandardOutput { get; set; } + public string StandardError { get; set; } + } + + public sealed class MultiOtpProcessExecutor + { + private const int FlushTimeoutMilliseconds = 2000; + + public MultiOtpProcessExecutor(string executablePath) + { + if (string.IsNullOrWhiteSpace(executablePath)) + { + throw new ArgumentException("The executable path is required.", "executablePath"); + } + + ExecutablePath = Path.GetFullPath(executablePath); + } + + public string ExecutablePath { get; private set; } + + public static string BuildArguments(IEnumerable arguments) + { + if (arguments == null) + { + throw new ArgumentNullException("arguments"); + } + + string[] escapedArguments = arguments + .Select(EscapeArgument) + .ToArray(); + + return string.Join(" ", escapedArguments); + } + + public static string EscapeArgument(string argument) + { + if (argument == null) + { + throw new ArgumentNullException("argument"); + } + + if (argument.Length == 0) + { + return "\"\""; + } + + StringBuilder escaped = new StringBuilder(argument.Length + 8); + int trailingBackslashes = 0; + + foreach (char character in argument) + { + if (character == '\\') + { + trailingBackslashes++; + continue; + } + + if (character == '"') + { + escaped.Append('\\', (trailingBackslashes * 2) + 1); + escaped.Append('"'); + } + else + { + escaped.Append('\\', trailingBackslashes); + escaped.Append(character); + } + + trailingBackslashes = 0; + } + + if (trailingBackslashes > 0) + { + escaped.Append('\\', trailingBackslashes * 2); + } + + escaped.Insert(0, '"'); + escaped.Append('"'); + return escaped.ToString(); + } + + public async Task ExecuteAsync( + IList arguments, + TimeSpan timeout, + CancellationToken cancellationToken) + { + if (arguments == null) + { + throw new ArgumentNullException("arguments"); + } + + if (!File.Exists(ExecutablePath)) + { + throw new FileNotFoundException("multiotp.exe was not found beside MultiOtpManager.exe.", ExecutablePath); + } + + cancellationToken.ThrowIfCancellationRequested(); + + ProcessStartInfo startInfo = new ProcessStartInfo(); + startInfo.FileName = ExecutablePath; + startInfo.Arguments = BuildArguments(arguments); + startInfo.WorkingDirectory = Path.GetDirectoryName(ExecutablePath); + startInfo.UseShellExecute = false; + startInfo.CreateNoWindow = true; + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + startInfo.StandardOutputEncoding = Encoding.UTF8; + startInfo.StandardErrorEncoding = Encoding.UTF8; + + using (Process process = new Process()) + using (CancellationTokenSource timeoutSource = new CancellationTokenSource()) + using (CancellationTokenSource linkedSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + timeoutSource.Token)) + { + process.StartInfo = startInfo; + process.EnableRaisingEvents = true; + + StringBuilder standardOutput = new StringBuilder(); + StringBuilder standardError = new StringBuilder(); + object outputLock = new object(); + TaskCompletionSource exitWaiter = new TaskCompletionSource(); + bool timedOut = false; + + EventHandler exitedHandler = delegate + { + exitWaiter.TrySetResult(null); + }; + + DataReceivedEventHandler outputHandler = delegate(object sender, DataReceivedEventArgs eventArgs) + { + if (eventArgs.Data != null) + { + lock (outputLock) + { + standardOutput.AppendLine(eventArgs.Data); + } + } + }; + + DataReceivedEventHandler errorHandler = delegate(object sender, DataReceivedEventArgs eventArgs) + { + if (eventArgs.Data != null) + { + lock (outputLock) + { + standardError.AppendLine(eventArgs.Data); + } + } + }; + + process.Exited += exitedHandler; + process.OutputDataReceived += outputHandler; + process.ErrorDataReceived += errorHandler; + + try + { + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + if (timeout > TimeSpan.Zero) + { + timeoutSource.CancelAfter(timeout); + } + + using (CancellationTokenRegistration registration = linkedSource.Token.Register(delegate + { + timedOut = !cancellationToken.IsCancellationRequested; + TryStopProcess(process); + exitWaiter.TrySetCanceled(linkedSource.Token); + })) + { + await exitWaiter.Task.ConfigureAwait(false); + } + + process.WaitForExit(FlushTimeoutMilliseconds); + + string output; + string error; + + lock (outputLock) + { + output = standardOutput.ToString(); + error = standardError.ToString(); + } + + return new ProcessRunResult + { + ExitCode = process.ExitCode, + StandardOutput = output, + StandardError = error + }; + } + catch (OperationCanceledException) + { + if (timedOut) + { + throw new TimeoutException("The multiOTP command timed out."); + } + + throw; + } + finally + { + process.Exited -= exitedHandler; + process.OutputDataReceived -= outputHandler; + process.ErrorDataReceived -= errorHandler; + TryWaitAfterCancellation(process); + } + } + } + + private static void TryStopProcess(Process process) + { + try + { + if (!process.HasExited) + { + process.Kill(); + } + } + catch (InvalidOperationException) + { + } + catch (System.ComponentModel.Win32Exception) + { + } + } + + private static void TryWaitAfterCancellation(Process process) + { + try + { + if (!process.HasExited) + { + process.WaitForExit(FlushTimeoutMilliseconds); + } + } + catch (InvalidOperationException) + { + } + catch (System.ComponentModel.Win32Exception) + { + } + } + } +} diff --git a/MultiOtpManager/Core/UserModels.cs b/MultiOtpManager/Core/UserModels.cs new file mode 100644 index 0000000..ec466a1 --- /dev/null +++ b/MultiOtpManager/Core/UserModels.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MultiOtpManager.Core +{ + public sealed class UserSummary + { + public string Name { get; set; } + public string TokenType { get; set; } + public string Status { get; set; } + } + + public sealed class UserDetail + { + private readonly Dictionary values = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public static UserDetail Parse(string text) + { + UserDetail detail = new UserDetail(); + if (string.IsNullOrEmpty(text)) + { + return detail; + } + + string[] lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.RemoveEmptyEntries); + foreach (string line in lines) + { + int separatorIndex = line.IndexOf(':'); + if (separatorIndex <= 0) + { + continue; + } + + string key = line.Substring(0, separatorIndex).Trim(); + string value = line.Substring(separatorIndex + 1).Trim(); + if (key.Length > 0) + { + detail.values[key] = value; + } + } + + return detail; + } + + public string Username + { + get { return GetValue("Information for user", "(unknown)"); } + } + + public string TokenType + { + get { return GetValue("Algorithm", "Not provided"); } + } + + public string Created + { + get { return "Not provided by this CLI version"; } + } + + public string Status + { + get + { + string activated = GetValue("Activated", "unknown"); + string locked = GetValue("Locked", "no"); + string delayed = GetValue("Delayed", "no"); + + List states = new List(); + states.Add(StringComparer.OrdinalIgnoreCase.Equals(activated, "yes") ? "Activated" : "Disabled"); + + if (StringComparer.OrdinalIgnoreCase.Equals(locked, "yes")) + { + states.Add("Locked"); + } + + if (StringComparer.OrdinalIgnoreCase.Equals(delayed, "yes")) + { + states.Add("Delayed"); + } + + return string.Join(", ", states.ToArray()); + } + } + + public string OtpDigits + { + get { return GetValue("OTP digits", "Not provided"); } + } + + public string Description + { + get { return GetValue("Description", string.Empty); } + } + + public string Email + { + get { return GetValue("Email", string.Empty); } + } + + public string MobilePhone + { + get { return GetValue("Mobile phone", string.Empty); } + } + + public string ToMaskedDisplayText() + { + IEnumerable lines = values + .OrderBy(delegate(KeyValuePair item) { return item.Key; }) + .Select(delegate(KeyValuePair item) + { + if (IsSensitiveKey(item.Key)) + { + return item.Key + ": ********"; + } + + return item.Key + ": " + item.Value; + }); + + return string.Join(Environment.NewLine, lines.ToArray()); + } + + private string GetValue(string key, string fallback) + { + string value; + return values.TryGetValue(key, out value) && !string.IsNullOrWhiteSpace(value) ? value : fallback; + } + + private static bool IsSensitiveKey(string key) + { + string lowerCaseKey = key.ToLowerInvariant(); + return lowerCaseKey.Contains("seed") || + lowerCaseKey.Contains("secret") || + lowerCaseKey.Contains("password") || + lowerCaseKey.Contains("pin"); + } + } +} diff --git a/MultiOtpManager/MainWindow.xaml b/MultiOtpManager/MainWindow.xaml new file mode 100644 index 0000000..6ca14d0 --- /dev/null +++ b/MultiOtpManager/MainWindow.xaml @@ -0,0 +1,465 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +