Skip to content

Commit

Permalink
Working
Browse files Browse the repository at this point in the history
  • Loading branch information
Aldaviva committed Mar 14, 2024
0 parents commit 8374f77
Show file tree
Hide file tree
Showing 64 changed files with 2,176 additions and 0 deletions.
41 changes: 41 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
[*]

# Exception Analyzers: Exception adjustments syntax error
# default = error
; dotnet_diagnostic.Ex0001.severity = none

# Exception Analyzers: Exception adjustments syntax error: Symbol does not exist or identifier is invalid
# default = warning
; dotnet_diagnostic.Ex0002.severity = none

# Exception Analyzers: Member may throw undocumented exception
# default = warning
dotnet_diagnostic.Ex0100.severity = none

# Exception Analyzers: Member accessor may throw undocumented exception
# default = warning
dotnet_diagnostic.Ex0101.severity = none

# Exception Analyzers: Implicit constructor may throw undocumented exception
# default = warning
dotnet_diagnostic.Ex0103.severity = none

# Exception Analyzers: Member initializer may throw undocumented exception
# default = warning
dotnet_diagnostic.Ex0104.severity = none

# Exception Analyzers: Delegate created from member may throw undocumented exception
# default = silent
; dotnet_diagnostic.Ex0120.severity = none

# Exception Analyzers: Delegate created from anonymous function may throw undocumented exception
# default = silent
; dotnet_diagnostic.Ex0121.severity = none

# Exception Analyzers: Member is documented as throwing exception not documented on member in base or interface type
# default = warning
dotnet_diagnostic.Ex0200.severity = none

# Exception Analyzers: Member accessor is documented as throwing exception not documented on member in base or interface type
# default = warning
dotnet_diagnostic.Ex0201.severity = none
Binary file added .github/images/authenticator-prompt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/images/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/images/usb-prompt.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: .NET

on:
push:
branches: [ master ]
workflow_dispatch:

jobs:
build:
env:
ProjectName: AuthenticatorChooser

runs-on: windows-latest

steps:
- name: Clone
uses: actions/checkout@v4

- name: Restore
run: dotnet restore --locked-mode --verbosity normal

- name: Build
run: dotnet build ${{ env.ProjectName }} --no-restore --configuration Release --no-self-contained --verbosity normal

- name: Publish
run: dotnet publish ${{ env.ProjectName }} --no-build --configuration Release -p:PublishSingleFile=true --self-contained false

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ env.ProjectName }}.exe
path: ${{ env.ProjectName }}/bin/Release/net8.0-windows/win-x64/publish/*.exe
if-no-files-found: error
32 changes: 32 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@

#Ignore thumbnails created by Windows
Thumbs.db
#Ignore files built by Visual Studio
*.obj
*.exe
*.pdb
*.user
*.aps
*.pch
*.vspscc
*_i.c
*_p.c
*.ncb
*.suo
*.tlb
*.tlh
*.bak
*.cache
*.ilk
*.log
[Bb]in
[Dd]ebug*/
*.lib
*.sbr
obj/
[Rr]elease*/
_ReSharper*/
[Tt]est[Rr]esult*
.vs/
#Nuget packages folder
packages/
25 changes: 25 additions & 0 deletions AuthenticatorChooser.sln
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34701.34
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AuthenticatorChooser", "AuthenticatorChooser\AuthenticatorChooser.csproj", "{24618EB8-29AF-47BF-A5D2-5A8C8E724991}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{24618EB8-29AF-47BF-A5D2-5A8C8E724991}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{24618EB8-29AF-47BF-A5D2-5A8C8E724991}.Debug|Any CPU.Build.0 = Debug|Any CPU
{24618EB8-29AF-47BF-A5D2-5A8C8E724991}.Release|Any CPU.ActiveCfg = Release|Any CPU
{24618EB8-29AF-47BF-A5D2-5A8C8E724991}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C11C7C56-6448-422F-A1C6-62859829E0CE}
EndGlobalSection
EndGlobal
50 changes: 50 additions & 0 deletions AuthenticatorChooser/AuthenticatorChooser.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<Version>0.0.0</Version>
<Authors>Ben Hutchison</Authors>
<Copyright>© 2024 $(Authors)</Copyright>
<Company>$(Authors)</Company>
<RollForward>major</RollForward>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>YubiKey.ico</ApplicationIcon>
<NeutralLanguage>en</NeutralLanguage>
</PropertyGroup>

<ItemGroup>
<Content Include="YubiKey.ico" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="mwinapi" Version="0.3.0.5" />
<PackageReference Include="throttledebounce" Version="2.0.0" />
<PackageReference Include="Workshell.PE.Resources" Version="3.0.0.130" />
</ItemGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.WindowsDesktop.App" /> <!-- UseWindowsForms is insufficient to refer to UIAutomationClient -->
</ItemGroup>

<ItemGroup>
<Compile Update="Resources\Strings.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Strings.resx</DependentUpon>
</Compile>
</ItemGroup>

<ItemGroup>
<EmbeddedResource Update="Resources\Strings.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Strings.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>

</Project>
24 changes: 24 additions & 0 deletions AuthenticatorChooser/Extensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using ManagedWinapi.Windows;
using System.Windows.Automation;

namespace AuthenticatorChooser;

public static class Extensions {

public static IntPtr toHwnd(this AutomationElement element) {
return new IntPtr(element.Current.NativeWindowHandle);
}

public static SystemWindow toSystemWindow(this AutomationElement element) {
return new SystemWindow(element.toHwnd());
}

public static AutomationElement toAutomationElement(this SystemWindow window) {
return AutomationElement.FromHandle(window.HWnd);
}

public static IEnumerable<AutomationElement> children(this AutomationElement parent) {
return parent.FindAll(TreeScope.Children, Condition.TrueCondition).Cast<AutomationElement>();
}

}
84 changes: 84 additions & 0 deletions AuthenticatorChooser/I18N.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using AuthenticatorChooser.Resources;
using System.Globalization;
using Workshell.PE;
using Workshell.PE.Resources;
using Workshell.PE.Resources.Strings;

namespace AuthenticatorChooser;

public static class I18N {

private const string FIDOCREDPROV_MUI_FILENAME = "fidocredprov.dll.mui";

public enum Key {

SECURITY_KEY,
SMARTPHONE,
WINDOWS

}

private static readonly IReadOnlyDictionary<Key, string?> RUNTIME_OS_FILE_STRINGS;

static I18N() {
StringTableResource.Register();

string fidocredprovMuiFilePath = Path.Combine(Environment.GetEnvironmentVariable("SystemRoot") ?? "C:\\Windows", "System32", CultureInfo.CurrentUICulture.Name, FIDOCREDPROV_MUI_FILENAME);

IList<string?> peFileStrings = getPeFileStrings(fidocredprovMuiFilePath, [
(15, 230),
(15, 231),
(15, 232)
]);

var strings = new Dictionary<Key, string?> {
[Key.SECURITY_KEY] = peFileStrings[0],
[Key.SMARTPHONE] = peFileStrings[1],
[Key.WINDOWS] = peFileStrings[2]
};
RUNTIME_OS_FILE_STRINGS = strings.AsReadOnly();
}

public static string getStringCompileTime(Key key) => key switch {
Key.SECURITY_KEY => Strings.securityKey,
Key.SMARTPHONE => Strings.smartphone,
Key.WINDOWS => Strings.windows,
_ => throw new ArgumentOutOfRangeException(nameof(key), key, null)
};

public static string? getStringRuntime(Key key) => RUNTIME_OS_FILE_STRINGS[key];

public static IEnumerable<string> getStrings(Key key) {
yield return getStringCompileTime(key);

if (getStringRuntime(key) is { } runtimeString) {
yield return runtimeString;
}
}

public static string? getPeFileString(string peFile, int stringTableId, int stringTableEntryId) {
return getPeFileStrings(peFile, [(stringTableId, stringTableEntryId)])[0];
}

public static IList<string?> getPeFileStrings(string peFile, IList<(int stringTableId, int stringTableEntryId)> queries) {

using PortableExecutableImage file = PortableExecutableImage.FromFile(peFile);

IDictionary<int, StringTable?> stringTableCache = new Dictionary<int, StringTable?>();
ResourceType? stringTables = ResourceCollection.Get(file).FirstOrDefault(type => type.Id == ResourceType.String);
IList<string?> results = new List<string?>(queries.Count);

foreach ((int stringTableId, int stringTableEntryId) in queries) {
if (!stringTableCache.TryGetValue(stringTableId, out StringTable? stringTable)) {
stringTable = (stringTables?.FirstOrDefault(resource => resource.Id == stringTableId) as StringTableResource)?.GetTable();

stringTableCache[stringTableId] = stringTable;
}

results.Add(stringTable?.FirstOrDefault(entry => entry.Id == stringTableEntryId)?.Value);
}

return results;
}

}
87 changes: 87 additions & 0 deletions AuthenticatorChooser/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using AuthenticatorChooser.WindowOpening;
using ManagedWinapi.Windows;
using System.Diagnostics;
using System.Windows.Automation;
using System.Windows.Forms;
using System.Windows.Input;
using ThrottleDebounce;

namespace AuthenticatorChooser;

internal static class Program {

private static readonly TimeSpan UI_RETRY_DELAY = TimeSpan.FromMilliseconds(8);

[STAThread]
public static void Main() {
Application.SetCompatibleTextRenderingDefault(false);
Application.EnableVisualStyles();
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);

using WindowOpeningListener windowOpeningListener = new WindowOpeningListenerImpl();
windowOpeningListener.windowOpened += (_, window) => chooseUsbSecurityKey(window);

foreach (SystemWindow fidoPromptWindow in SystemWindow.FilterToplevelWindows(isFidoPromptWindow)) {
chooseUsbSecurityKey(fidoPromptWindow);
}

Console.WriteLine();
Application.Run();
}

private static void chooseUsbSecurityKey(SystemWindow fidoPrompt) {
Stopwatch stopwatch = Stopwatch.StartNew();
if (!isFidoPromptWindow(fidoPrompt)) {
Console.WriteLine($"Window 0x{fidoPrompt.HWnd:x} is not a Windows Security window");
return;
}

Console.WriteLine($"Found FIDO prompt window (HWND=0x{fidoPrompt.HWnd:x}) after {stopwatch.ElapsedMilliseconds:N0} ms");
AutomationElement fidoEl = fidoPrompt.toAutomationElement();
Console.WriteLine($"Converted window to AutomationElement after {stopwatch.ElapsedMilliseconds:N0} ms");

AutomationElement outerScrollViewer = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ClassNameProperty, "ScrollViewer"));
if (outerScrollViewer.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.NameProperty, "Sign in with your passkey")) == null) { // not localized by Windows
Console.WriteLine("Window is not a passkey reading prompt");
return;
}

Console.WriteLine($"Window is the passkey prompt after {stopwatch.ElapsedMilliseconds:N0} ms");

List<AutomationElement> listItems = Retrier.Attempt(_ =>
outerScrollViewer.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "CredentialsList")).children().ToList(), // ClassName=ListView
maxAttempts: 25, delay: _ => UI_RETRY_DELAY, beforeRetry: () => Console.WriteLine("No list found, retrying"));
Console.WriteLine($"Found list of authenticator choices after {stopwatch.ElapsedMilliseconds:N0} ms");

if (listItems.FirstOrDefault(listItem => nameEndsWithAny(listItem, I18N.getStrings(I18N.Key.SECURITY_KEY))) is not { } securityKeyButton) {
Console.WriteLine("USB security key is not a choice, skipping");
return;
}

Console.WriteLine($"Prompted for credential type after {stopwatch.ElapsedMilliseconds:N0} ms");
((SelectionItemPattern) securityKeyButton.GetCurrentPattern(SelectionItemPattern.Pattern)).Select();
Console.WriteLine($"USB key selected after {stopwatch.ElapsedMilliseconds:N0} ms");

if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift)) {
Console.WriteLine("Shift is pressed, not submitting dialog box");
return;
} else if (!listItems.All(listItem => listItem == securityKeyButton || nameEndsWithAny(listItem, I18N.getStrings(I18N.Key.SMARTPHONE)))) {
Console.WriteLine("Dialog box has a choice that isn't smartphone or USB security key (such as PIN or biometrics), skipping because the user might want to choose it");
return;
}

AutomationElement nextButton = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "OkButton"));
((InvokePattern) nextButton.GetCurrentPattern(InvokePattern.Pattern)).Invoke();
stopwatch.Stop();
Console.WriteLine($"Next button clicked after {stopwatch.ElapsedMilliseconds:N0} ms");
}

private static bool nameEndsWithAny(AutomationElement element, IEnumerable<string?> suffices) {
string name = element.Current.Name;
return suffices.Any(suffix => suffix != null && name.EndsWith(suffix, StringComparison.CurrentCulture));
}

// name/title are localized, so don't use those
private static bool isFidoPromptWindow(SystemWindow window) => window.ClassName == "Credential Dialog Xaml Host";

}
Loading

0 comments on commit 8374f77

Please sign in to comment.