Skip to content

Commit

Permalink
#2: does not do anything on windows 11 24h2 [reading localized string…
Browse files Browse the repository at this point in the history
…s from DLLs at runtime was broken, and dialog title needed to be localized, added ARM64 build]
  • Loading branch information
Aldaviva committed Jun 20, 2024
1 parent 3034bcd commit 7646972
Show file tree
Hide file tree
Showing 51 changed files with 430 additions and 239 deletions.
14 changes: 10 additions & 4 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ jobs:

runs-on: windows-latest

strategy:
matrix:
include:
- targetPlatform: win-x64
- targetPlatform: win-arm64

steps:
- name: Clone
uses: actions/checkout@v4
Expand All @@ -20,14 +26,14 @@ jobs:
run: dotnet restore --locked-mode --verbosity normal

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

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

- 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
name: ${{ env.ProjectName }}-${{ matrix.targetPlatform }}
path: ${{ env.ProjectName }}/bin/Release/net8.0-windows/${{ matrix.targetPlatform }}/publish/*.exe
if-no-files-found: error
14 changes: 9 additions & 5 deletions AuthenticatorChooser/AuthenticatorChooser.csproj
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--EXTERNAL_PROPERTIES: GITHUB_ACTIONS-->
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.0.1</Version>
<Version>0.1.0</Version>
<Authors>Ben Hutchison</Authors>
<Copyright>© 2024 $(Authors)</Copyright>
<Company>$(Authors)</Company>
Expand All @@ -23,7 +24,7 @@

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

Expand All @@ -46,4 +47,7 @@
</EmbeddedResource>
</ItemGroup>

</Project>
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(Configuration)' == 'Release'">
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
</PropertyGroup>
</Project>
72 changes: 38 additions & 34 deletions AuthenticatorChooser/I18N.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ namespace AuthenticatorChooser;

public static class I18N {

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

public enum Key {

SECURITY_KEY,
SMARTPHONE,
WINDOWS
WINDOWS,
SIGN_IN_WITH_YOUR_PASSKEY

}

Expand All @@ -23,30 +22,35 @@ public enum Key {
static I18N() {
StringTableResource.Register();

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

IList<string?> peFileStrings = getPeFileStrings(fidocredprovMuiFilePath, [
IList<string?> fidoCredProvStrings = getPeFileStrings(Path.Combine(localizedFilesDir, "fidocredprov.dll.mui"), [
(15, 230),
(15, 231),
(15, 231), // also appears in webauthn.dll.mui string table 4 entries 50 and 56
(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();
IList<string?> webauthnStrings = getPeFileStrings(Path.Combine(localizedFilesDir, "webauthn.dll.mui"), [
(4, 53) // entry 63 has the same value, not sure which one is used
]);

RUNTIME_OS_FILE_STRINGS = new Dictionary<Key, string?> {
[Key.SECURITY_KEY] = fidoCredProvStrings[0],
[Key.SMARTPHONE] = fidoCredProvStrings[1],
[Key.WINDOWS] = fidoCredProvStrings[2],
[Key.SIGN_IN_WITH_YOUR_PASSKEY] = webauthnStrings[0]
}.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)
private static string getStringCompileTime(Key key) => key switch {
Key.SECURITY_KEY => Strings.securityKey,
Key.SMARTPHONE => Strings.smartphone,
Key.WINDOWS => Strings.windows,
Key.SIGN_IN_WITH_YOUR_PASSKEY => Strings.signInWithYourPasskey,
_ => throw new ArgumentOutOfRangeException(nameof(key), key, null)
};

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

public static IEnumerable<string> getStrings(Key key) {
yield return getStringCompileTime(key);
Expand All @@ -56,29 +60,29 @@ public static IEnumerable<string> getStrings(Key key) {
}
}

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) {
private static IList<string?> getPeFileStrings(string peFile, IList<(int stringTableId, int stringTableEntryId)> queries) {
try {
using PortableExecutableImage file = PortableExecutableImage.FromFile(peFile);

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);

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)) {
StringTableResource? stringTableResource = stringTables?.FirstOrDefault(resource => resource.Id == stringTableId) as StringTableResource;
stringTable = stringTableResource?.GetTable(stringTableResource.Languages[0]);

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;
}

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

results.Add(stringTable?.FirstOrDefault(entry => entry.Id == stringTableEntryId)?.Value);
}
return results;
} catch (FileNotFoundException) { } catch (DirectoryNotFoundException) { }

return results;
return [];
}

}
69 changes: 5 additions & 64 deletions AuthenticatorChooser/Program.cs
Original file line number Diff line number Diff line change
@@ -1,87 +1,28 @@
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);
windowOpeningListener.windowOpened += (_, window) => SecurityKeyChooser.chooseUsbSecurityKey(window);

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

_ = I18N.getStrings(I18N.Key.SMARTPHONE); // ensure localization is loaded eagerly

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";

}
9 changes: 9 additions & 0 deletions AuthenticatorChooser/Resources/Strings.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions AuthenticatorChooser/Resources/Strings.ar-SA.resx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
<value>2.0</value>
</resheader>
<data name="securityKey" xml:space="preserve">
<value>مفتاح الأمان</value>
Expand All @@ -21,4 +21,7 @@
<data name="windows" xml:space="preserve">
<value>جهاز Windows هذا</value>
</data>
<data name="signInWithYourPasskey" xml:space="preserve">
<value>تسجيل الدخول باستخدام رمز المرور الخاص بك</value>
</data>
</root>
9 changes: 6 additions & 3 deletions AuthenticatorChooser/Resources/Strings.bg-BG.resx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
<value>2.0</value>
</resheader>
<data name="securityKey" xml:space="preserve">
<value>Защитен ключ</value>
Expand All @@ -21,4 +21,7 @@
<data name="windows" xml:space="preserve">
<value>Това устройство с Windows</value>
</data>
<data name="signInWithYourPasskey" xml:space="preserve">
<value>Влезте с вашия ключ за достъп</value>
</data>
</root>
9 changes: 6 additions & 3 deletions AuthenticatorChooser/Resources/Strings.ca-ES.resx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
<value>2.0</value>
</resheader>
<data name="securityKey" xml:space="preserve">
<value>Clau de seguretat</value>
Expand All @@ -21,4 +21,7 @@
<data name="windows" xml:space="preserve">
<value>Este dispositiu Windows</value>
</data>
<data name="signInWithYourPasskey" xml:space="preserve">
<value>Inicia la sessió amb la clau d'accés</value>
</data>
</root>
Loading

0 comments on commit 7646972

Please sign in to comment.