Skip to content

Commit b15d36f

Browse files
committed
#5: windowOpened fires too quickly
1 parent bcd10b1 commit b15d36f

File tree

2 files changed

+107
-72
lines changed

2 files changed

+107
-72
lines changed

AuthenticatorChooser/Program.cs

Lines changed: 20 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,9 @@ public static int Main(string[] args) {
1919
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
2020

2121
if (args.Intersect(["--help", "/help", "-h", "/h", "-?", "/?"], CASE_INSENSITIVE_COMPARER).Any()) {
22-
string processFilename = Path.GetFileName(Environment.ProcessPath)!;
23-
MessageBox.Show(
24-
$"""
25-
{processFilename}
26-
Runs this program in the background normally, waiting for FIDO credentials dialog boxes to open and choosing the Security Key option each time.
27-
28-
{processFilename} --autostart-on-logon
29-
Registers this program to start automatically every time the current user logs on to Windows.
30-
31-
{processFilename} --help
32-
Shows usage.
33-
34-
For more information, see https://github.com/Aldaviva/{PROGRAM_NAME}
35-
(press Ctrl+C to copy this message)
36-
""", $"{PROGRAM_NAME} usage", MessageBoxButtons.OK, MessageBoxIcon.Information);
22+
showUsage();
3723
} else if (args.Contains("--autostart-on-logon", CASE_INSENSITIVE_COMPARER)) {
38-
Registry.SetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run", PROGRAM_NAME, Environment.ProcessPath!);
24+
Registry.SetValue(@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run", PROGRAM_NAME, $"\"{Environment.ProcessPath}\"");
3925
} else {
4026
using Mutex singleInstanceLock = new(true, $@"Local\{PROGRAM_NAME}_{WindowsIdentity.GetCurrent().User?.Value}", out bool isOnlyInstance);
4127
if (!isOnlyInstance) return 1;
@@ -55,4 +41,22 @@ Shows usage.
5541
return 0;
5642
}
5743

44+
private static void showUsage() {
45+
string processFilename = Path.GetFileName(Environment.ProcessPath)!;
46+
MessageBox.Show(
47+
$"""
48+
{processFilename}
49+
Runs this program in the background normally, waiting for FIDO credentials dialog boxes to open and choosing the Security Key option each time.
50+
51+
{processFilename} --autostart-on-logon
52+
Registers this program to start automatically every time the current user logs on to Windows.
53+
54+
{processFilename} --help
55+
Shows usage.
56+
57+
For more information, see https://github.com/Aldaviva/{PROGRAM_NAME}
58+
(press Ctrl+C to copy this message)
59+
""", $"{PROGRAM_NAME} usage", MessageBoxButtons.OK, MessageBoxIcon.Information);
60+
}
61+
5862
}
Lines changed: 87 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,88 @@
1-
using ManagedWinapi.Windows;
2-
using System.Windows.Automation;
3-
using System.Windows.Input;
4-
using ThrottleDebounce;
5-
6-
namespace AuthenticatorChooser;
7-
8-
public static class SecurityKeyChooser {
9-
10-
private static readonly TimeSpan UI_RETRY_DELAY = TimeSpan.FromMilliseconds(8);
11-
12-
public static void chooseUsbSecurityKey(SystemWindow fidoPrompt) {
13-
if (isFidoPromptWindow(fidoPrompt)) {
14-
AutomationElement fidoEl = fidoPrompt.toAutomationElement();
15-
AutomationElement? outerScrollViewer = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ClassNameProperty, "ScrollViewer"));
16-
AutomationElement? promptTitleEl = outerScrollViewer?.FindFirst(TreeScope.Children, new AndCondition(
17-
new PropertyCondition(AutomationElement.ClassNameProperty, "TextBlock"),
18-
singleSafePropertyCondition(AutomationElement.NameProperty, I18N.getStrings(I18N.Key.SIGN_IN_WITH_YOUR_PASSKEY))));
19-
20-
if (outerScrollViewer != null && promptTitleEl != null) {
21-
List<AutomationElement> listItems = Retrier.Attempt(_ =>
22-
outerScrollViewer.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "CredentialsList")).children().ToList(),
23-
maxAttempts: 25, delay: _ => UI_RETRY_DELAY);
24-
25-
if (listItems.FirstOrDefault(listItem => nameContainsAny(listItem, I18N.getStrings(I18N.Key.SECURITY_KEY))) is { } securityKeyButton) {
26-
((SelectionItemPattern) securityKeyButton.GetCurrentPattern(SelectionItemPattern.Pattern)).Select();
27-
28-
if (!Keyboard.IsKeyDown(Key.LeftShift) && !Keyboard.IsKeyDown(Key.RightShift)
29-
&& listItems.All(listItem => listItem == securityKeyButton || nameContainsAny(listItem, I18N.getStrings(I18N.Key.SMARTPHONE)))) {
30-
31-
AutomationElement nextButton = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "OkButton"));
32-
((InvokePattern) nextButton.GetCurrentPattern(InvokePattern.Pattern)).Invoke();
33-
} // 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
34-
} // Otherwise USB security key was not an option
35-
} // Otherwise could not find title, might be a UAC prompt
36-
} // Otherwise not a credential prompt, wrong window class name
37-
}
38-
39-
// name/title are localized, so don't use those
40-
// #4: unfortunately, this class name is shared with the UAC prompt, detectable when desktop dimming is disabled
41-
public static bool isFidoPromptWindow(SystemWindow window) => window.ClassName == "Credential Dialog Xaml Host";
42-
43-
private static bool nameContainsAny(AutomationElement element, IEnumerable<string?> suffices) {
44-
string name = element.Current.Name;
45-
return suffices.Any(suffix => suffix != null && name.Contains(suffix, StringComparison.CurrentCulture));
46-
}
47-
48-
private static Condition singleSafePropertyCondition(AutomationProperty property, IEnumerable<string> allowedNames) {
49-
Condition[] propertyConditions = allowedNames.Select<string, Condition>(allowedName => new PropertyCondition(property, allowedName)).ToArray();
50-
return propertyConditions.Length switch {
51-
0 => Condition.FalseCondition,
52-
1 => propertyConditions[0],
53-
_ => new OrCondition(propertyConditions)
54-
};
55-
}
56-
1+
using ManagedWinapi.Windows;
2+
using System.Windows.Automation;
3+
using System.Windows.Input;
4+
using ThrottleDebounce;
5+
6+
namespace AuthenticatorChooser;
7+
8+
public static class SecurityKeyChooser {
9+
10+
// #4: unfortunately, this class name is shared with the UAC prompt, detectable when desktop dimming is disabled
11+
private const string WINDOW_CLASS_NAME = "Credential Dialog Xaml Host";
12+
13+
public static void chooseUsbSecurityKey(SystemWindow fidoPrompt) {
14+
Console.WriteLine();
15+
if (!isFidoPromptWindow(fidoPrompt)) {
16+
Console.WriteLine($"Window 0x{fidoPrompt.HWnd:x} is not a Windows Security window");
17+
return;
18+
}
19+
20+
AutomationElement fidoEl = fidoPrompt.toAutomationElement();
21+
AutomationElement? outerScrollViewer = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.ClassNameProperty, "ScrollViewer"));
22+
AutomationElement? promptTitleEl = outerScrollViewer?.FindFirst(TreeScope.Children, new AndCondition(
23+
new PropertyCondition(AutomationElement.ClassNameProperty, "TextBlock"),
24+
singletonSafePropertyCondition(AutomationElement.NameProperty, false, I18N.getStrings(I18N.Key.SIGN_IN_WITH_YOUR_PASSKEY))));
25+
26+
if (outerScrollViewer == null || promptTitleEl == null) {
27+
Console.WriteLine("Window is not a passkey choice prompt");
28+
return;
29+
}
30+
31+
Condition idCondition = new PropertyCondition(AutomationElement.AutomationIdProperty, "CredentialsList");
32+
List<AutomationElement> authenticatorChoices = Retrier.Attempt(_ =>
33+
outerScrollViewer.FindFirst(TreeScope.Children, idCondition).children().ToList(),
34+
maxAttempts: 18, // ~5 sec
35+
delay: attempt => TimeSpan.FromMilliseconds(Math.Min(500, 1 << attempt)));
36+
37+
IEnumerable<string> securityKeyLabelPossibilities = I18N.getStrings(I18N.Key.SECURITY_KEY);
38+
if (authenticatorChoices.FirstOrDefault(choice => nameContainsAny(choice, securityKeyLabelPossibilities)) is not { } securityKeyChoice) {
39+
Console.WriteLine("USB security key is not a choice, skipping");
40+
return;
41+
}
42+
43+
((SelectionItemPattern) securityKeyChoice.GetCurrentPattern(SelectionItemPattern.Pattern)).Select();
44+
Console.WriteLine("USB security key selected");
45+
46+
AutomationElement nextButton = fidoEl.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "OkButton"));
47+
48+
if (Keyboard.IsKeyDown(Key.LeftShift) || Keyboard.IsKeyDown(Key.RightShift)) {
49+
nextButton.SetFocus();
50+
Console.WriteLine("Shift is pressed, not submitting dialog box");
51+
return;
52+
} else if (!authenticatorChoices.All(choice => choice == securityKeyChoice || nameContainsAny(choice, I18N.getStrings(I18N.Key.SMARTPHONE)))) {
53+
nextButton.SetFocus();
54+
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), " +
55+
"skipping because the user might want to choose it");
56+
return;
57+
}
58+
59+
((InvokePattern) nextButton.GetCurrentPattern(InvokePattern.Pattern)).Invoke();
60+
Console.WriteLine("Next button pressed");
61+
}
62+
63+
// name/title are localized, so don't use those
64+
public static bool isFidoPromptWindow(SystemWindow window) => window.ClassName == WINDOW_CLASS_NAME;
65+
66+
private static bool nameContainsAny(AutomationElement element, IEnumerable<string?> possibleSubstrings) {
67+
string name = element.Current.Name;
68+
return possibleSubstrings.Any(possibleSubstring => possibleSubstring != null && name.Contains(possibleSubstring, StringComparison.CurrentCulture));
69+
}
70+
71+
/// <summary>
72+
/// <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>
73+
/// <para>This avoids a crash in the <see cref="AndCondition"/> and <see cref="OrCondition"/> constructors if the array has size 1.</para>
74+
/// </summary>
75+
/// <param name="property">The name of the UI property to match against, such as <see cref="AutomationElement.NameProperty"/> or <see cref="AutomationElement.AutomationIdProperty"/>.</param>
76+
/// <param name="and"><c>true</c> to make a conjunction (AND), <c>false</c> to make a disjunction (OR)</param>
77+
/// <param name="values">Zero or more property values to match against.</param>
78+
/// <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>
79+
private static Condition singletonSafePropertyCondition(AutomationProperty property, bool and, IEnumerable<string> values) {
80+
Condition[] propertyConditions = values.Select<string, Condition>(allowedValue => new PropertyCondition(property, allowedValue)).ToArray();
81+
return propertyConditions.Length switch {
82+
0 => and ? Condition.TrueCondition : Condition.FalseCondition,
83+
1 => propertyConditions[0],
84+
_ => and ? new AndCondition(propertyConditions) : new OrCondition(propertyConditions)
85+
};
86+
}
87+
5788
}

0 commit comments

Comments
 (0)