Skip to content

Commit

Permalink
#5: windowOpened fires too quickly
Browse files Browse the repository at this point in the history
  • Loading branch information
Aldaviva committed Sep 19, 2024
1 parent bcd10b1 commit b15d36f
Show file tree
Hide file tree
Showing 2 changed files with 107 additions and 72 deletions.
36 changes: 20 additions & 16 deletions AuthenticatorChooser/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,9 @@ public static int Main(string[] args) {
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);

if (args.Intersect(["--help", "/help", "-h", "/h", "-?", "/?"], CASE_INSENSITIVE_COMPARER).Any()) {
string processFilename = Path.GetFileName(Environment.ProcessPath)!;
MessageBox.Show(
$"""
{processFilename}
Runs this program in the background normally, waiting for FIDO credentials dialog boxes to open and choosing the Security Key option each time.

{processFilename} --autostart-on-logon
Registers this program to start automatically every time the current user logs on to Windows.

{processFilename} --help
Shows usage.

For more information, see https://github.com/Aldaviva/{PROGRAM_NAME}
(press Ctrl+C to copy this message)
""", $"{PROGRAM_NAME} usage", MessageBoxButtons.OK, MessageBoxIcon.Information);
showUsage();
} else if (args.Contains("--autostart-on-logon", CASE_INSENSITIVE_COMPARER)) {
Registry.SetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run", PROGRAM_NAME, Environment.ProcessPath!);
Registry.SetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run", PROGRAM_NAME, $"\"{Environment.ProcessPath}\"");
} else {
using Mutex singleInstanceLock = new(true, $@"Local\{PROGRAM_NAME}_{WindowsIdentity.GetCurrent().User?.Value}", out bool isOnlyInstance);
if (!isOnlyInstance) return 1;
Expand All @@ -55,4 +41,22 @@ Shows usage.
return 0;
}

private static void showUsage() {
string processFilename = Path.GetFileName(Environment.ProcessPath)!;
MessageBox.Show(
$"""
{processFilename}
Runs this program in the background normally, waiting for FIDO credentials dialog boxes to open and choosing the Security Key option each time.

{processFilename} --autostart-on-logon
Registers this program to start automatically every time the current user logs on to Windows.

{processFilename} --help
Shows usage.

For more information, see https://github.com/Aldaviva/{PROGRAM_NAME}
(press Ctrl+C to copy this message)
""", $"{PROGRAM_NAME} usage", MessageBoxButtons.OK, MessageBoxIcon.Information);
}

}
143 changes: 87 additions & 56 deletions AuthenticatorChooser/SecurityKeyChooser.cs
Original file line number Diff line number Diff line change
@@ -1,57 +1,88 @@
using ManagedWinapi.Windows;
using System.Windows.Automation;
using System.Windows.Input;
using ThrottleDebounce;

namespace AuthenticatorChooser;

public static class SecurityKeyChooser {

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

public static void chooseUsbSecurityKey(SystemWindow fidoPrompt) {
if (isFidoPromptWindow(fidoPrompt)) {
AutomationElement fidoEl = fidoPrompt.toAutomationElement();
AutomationElement? outerScrollViewer = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ClassNameProperty, "ScrollViewer"));
AutomationElement? promptTitleEl = outerScrollViewer?.FindFirst(TreeScope.Children, new AndCondition(
new PropertyCondition(AutomationElement.ClassNameProperty, "TextBlock"),
singleSafePropertyCondition(AutomationElement.NameProperty, I18N.getStrings(I18N.Key.SIGN_IN_WITH_YOUR_PASSKEY))));

if (outerScrollViewer != null && promptTitleEl != null) {
List<AutomationElement> listItems = Retrier.Attempt(_ =>
outerScrollViewer.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "CredentialsList")).children().ToList(),
maxAttempts: 25, delay: _ => UI_RETRY_DELAY);

if (listItems.FirstOrDefault(listItem => nameContainsAny(listItem, I18N.getStrings(I18N.Key.SECURITY_KEY))) is { } securityKeyButton) {
((SelectionItemPattern) securityKeyButton.GetCurrentPattern(SelectionItemPattern.Pattern)).Select();

if (!Keyboard.IsKeyDown(Key.LeftShift) && !Keyboard.IsKeyDown(Key.RightShift)
&& listItems.All(listItem => listItem == securityKeyButton || nameContainsAny(listItem, I18N.getStrings(I18N.Key.SMARTPHONE)))) {

AutomationElement nextButton = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "OkButton"));
((InvokePattern) nextButton.GetCurrentPattern(InvokePattern.Pattern)).Invoke();
} // Otherwise shift key was held down, or prompt contained extra options besides USB security key and pairing a new phone, such as an existing paired phone, PIN, or fingerprint
} // Otherwise USB security key was not an option
} // Otherwise could not find title, might be a UAC prompt
} // Otherwise not a credential prompt, wrong window class name
}

// name/title are localized, so don't use those
// #4: unfortunately, this class name is shared with the UAC prompt, detectable when desktop dimming is disabled
public static bool isFidoPromptWindow(SystemWindow window) => window.ClassName == "Credential Dialog Xaml Host";

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

private static Condition singleSafePropertyCondition(AutomationProperty property, IEnumerable<string> allowedNames) {
Condition[] propertyConditions = allowedNames.Select<string, Condition>(allowedName => new PropertyCondition(property, allowedName)).ToArray();
return propertyConditions.Length switch {
0 => Condition.FalseCondition,
1 => propertyConditions[0],
_ => new OrCondition(propertyConditions)
};
}

using ManagedWinapi.Windows;
using System.Windows.Automation;
using System.Windows.Input;
using ThrottleDebounce;

namespace AuthenticatorChooser;

public static class SecurityKeyChooser {

// #4: unfortunately, this class name is shared with the UAC prompt, detectable when desktop dimming is disabled
private const string WINDOW_CLASS_NAME = "Credential Dialog Xaml Host";

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

AutomationElement fidoEl = fidoPrompt.toAutomationElement();
AutomationElement? outerScrollViewer = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ClassNameProperty, "ScrollViewer"));
AutomationElement? promptTitleEl = outerScrollViewer?.FindFirst(TreeScope.Children, new AndCondition(
new PropertyCondition(AutomationElement.ClassNameProperty, "TextBlock"),
singletonSafePropertyCondition(AutomationElement.NameProperty, false, I18N.getStrings(I18N.Key.SIGN_IN_WITH_YOUR_PASSKEY))));

if (outerScrollViewer == null || promptTitleEl == null) {
Console.WriteLine("Window is not a passkey choice prompt");
return;
}

Condition idCondition = new PropertyCondition(AutomationElement.AutomationIdProperty, "CredentialsList");
List<AutomationElement> authenticatorChoices = Retrier.Attempt(_ =>
outerScrollViewer.FindFirst(TreeScope.Children, idCondition).children().ToList(),
maxAttempts: 18, // ~5 sec
delay: attempt => TimeSpan.FromMilliseconds(Math.Min(500, 1 << attempt)));

IEnumerable<string> securityKeyLabelPossibilities = I18N.getStrings(I18N.Key.SECURITY_KEY);
if (authenticatorChoices.FirstOrDefault(choice => nameContainsAny(choice, securityKeyLabelPossibilities)) is not { } securityKeyChoice) {
Console.WriteLine("USB security key is not a choice, skipping");
return;
}

((SelectionItemPattern) securityKeyChoice.GetCurrentPattern(SelectionItemPattern.Pattern)).Select();
Console.WriteLine("USB security key selected");

AutomationElement nextButton = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "OkButton"));

if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift)) {
nextButton.SetFocus();
Console.WriteLine("Shift is pressed, not submitting dialog box");
return;
} else if (!authenticatorChoices.All(choice => choice == securityKeyChoice || nameContainsAny(choice, I18N.getStrings(I18N.Key.SMARTPHONE)))) {
nextButton.SetFocus();
Console.WriteLine("Dialog box has a choice that is neither pairing a new phone nor USB security key (such as an existing phone, PIN, or biometrics), " +
"skipping because the user might want to choose it");
return;
}

((InvokePattern) nextButton.GetCurrentPattern(InvokePattern.Pattern)).Invoke();
Console.WriteLine("Next button pressed");
}

// name/title are localized, so don't use those
public static bool isFidoPromptWindow(SystemWindow window) => window.ClassName == WINDOW_CLASS_NAME;

private static bool nameContainsAny(AutomationElement element, IEnumerable<string?> possibleSubstrings) {
string name = element.Current.Name;
return possibleSubstrings.Any(possibleSubstring => possibleSubstring != null && name.Contains(possibleSubstring, StringComparison.CurrentCulture));
}

/// <summary>
/// <para>Create an <see cref="AndCondition"/> or <see cref="OrCondition"/> for a <paramref name="property"/> from a series of <paramref name="values"/>, which have fewer than 2 items in it.</para>
/// <para>This avoids a crash in the <see cref="AndCondition"/> and <see cref="OrCondition"/> constructors if the array has size 1.</para>
/// </summary>
/// <param name="property">The name of the UI property to match against, such as <see cref="AutomationElement.NameProperty"/> or <see cref="AutomationElement.AutomationIdProperty"/>.</param>
/// <param name="and"><c>true</c> to make a conjunction (AND), <c>false</c> to make a disjunction (OR)</param>
/// <param name="values">Zero or more property values to match against.</param>
/// <returns>A <see cref="Condition"/> that matches the values against the property, without throwing an <see cref="ArgumentException"/> if <paramref name="values"/> has length &lt; 2.</returns>
private static Condition singletonSafePropertyCondition(AutomationProperty property, bool and, IEnumerable<string> values) {
Condition[] propertyConditions = values.Select<string, Condition>(allowedValue => new PropertyCondition(property, allowedValue)).ToArray();
return propertyConditions.Length switch {
0 => and ? Condition.TrueCondition : Condition.FalseCondition,
1 => propertyConditions[0],
_ => and ? new AndCondition(propertyConditions) : new OrCondition(propertyConditions)
};
}

}

0 comments on commit b15d36f

Please sign in to comment.