Skip to content

Commit

Permalink
#8: Catch COMException, added logging, extracted SecurityKeyChooser i…
Browse files Browse the repository at this point in the history
…nto lots of small methods to make stack traces more useful, package PDB file in CI build artifacts, added more robust argument parsing, skip when foreground window is the Alt+Tab window
  • Loading branch information
Aldaviva committed Oct 4, 2024
1 parent b15d36f commit 63203c9
Show file tree
Hide file tree
Showing 8 changed files with 392 additions and 201 deletions.
1 change: 1 addition & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ jobs:
with:
name: ${{ env.ProjectName }}-${{ matrix.targetPlatform }}
path: ${{ env.ProjectName }}/bin/Release/net8.0-windows/${{ matrix.targetPlatform }}/publish/*.exe
path: ${{ env.ProjectName }}/bin/Release/net8.0-windows/${{ matrix.targetPlatform }}/publish/*.pdb
if-no-files-found: error
5 changes: 4 additions & 1 deletion AuthenticatorChooser/AuthenticatorChooser.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Version>0.2.0</Version>
<Version>0.2.1</Version>
<Authors>Ben Hutchison</Authors>
<Copyright>© 2024 $(Authors)</Copyright>
<Company>$(Authors)</Company>
Expand All @@ -23,7 +23,10 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.1.1" />
<PackageReference Include="mwinapi" Version="0.3.0.5" />
<PackageReference Include="NLog" Version="5.3.4" />
<PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="ThrottleDebounce" Version="2.0.0" />
<PackageReference Include="Workshell.PE.Resources" Version="4.0.0.147" />
</ItemGroup>
Expand Down
160 changes: 86 additions & 74 deletions AuthenticatorChooser/I18N.cs
Original file line number Diff line number Diff line change
@@ -1,75 +1,87 @@
using AuthenticatorChooser.Resources;
using System.Collections.Frozen;
using System.Globalization;
using Workshell.PE;
using Workshell.PE.Resources;
using Workshell.PE.Resources.Strings;

namespace AuthenticatorChooser;

public static class I18N {

public enum Key {

SECURITY_KEY,
SMARTPHONE,
WINDOWS,
SIGN_IN_WITH_YOUR_PASSKEY

}

private static readonly FrozenDictionary<Key, IList<string>> STRINGS;

static I18N() {
StringTableResource.Register();

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

IList<string?> fidoCredProvStrings = getPeFileStrings(Path.Combine(localizedFilesDir, "fidocredprov.dll.mui"), [
(15, 230), // Security key
(15, 231), // Smartphone; also appears in webauthn.dll.mui string table 4 entries 50 and 56
(15, 232) // Windows
]);

IList<string?> webauthnStrings = getPeFileStrings(Path.Combine(localizedFilesDir, "webauthn.dll.mui"), [
(4, 53) // Sign In With Your Passkey title; entry 63 has the same value, not sure which one is used
]);

STRINGS = new Dictionary<Key, IList<string>> {
[Key.SECURITY_KEY] = getUniqueNonNullStrings(Strings.securityKey, fidoCredProvStrings[0]),
[Key.SMARTPHONE] = getUniqueNonNullStrings(Strings.smartphone, fidoCredProvStrings[1]),
[Key.WINDOWS] = getUniqueNonNullStrings(Strings.windows, fidoCredProvStrings[2]),
[Key.SIGN_IN_WITH_YOUR_PASSKEY] = getUniqueNonNullStrings(Strings.signInWithYourPasskey, webauthnStrings[0]),
}.ToFrozenDictionary();

static IList<string> getUniqueNonNullStrings(params string?[] strings) => strings.Compact().Distinct(StringComparer.CurrentCulture).ToList();
}

public static IEnumerable<string> getStrings(Key key) => STRINGS[key];

private static IList<string?> getPeFileStrings(string peFile, IList<(int stringTableId, int stringTableEntryId)> queries) {
try {
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)) {
StringTableResource? stringTableResource = stringTables?.FirstOrDefault(resource => resource.Id == stringTableId) as StringTableResource;
stringTable = stringTableResource?.GetTable(stringTableResource.Languages[0]);

stringTableCache[stringTableId] = stringTable;
}

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

return results;
} catch (FileNotFoundException) { } catch (DirectoryNotFoundException) { } catch (PortableExecutableImageException) { }

return Enumerable.Repeat<string?>(null, queries.Count).ToList();
}

using AuthenticatorChooser.Resources;
using Microsoft.Win32;
using System.Collections.Frozen;
using System.Globalization;
using Workshell.PE;
using Workshell.PE.Resources;
using Workshell.PE.Resources.Strings;

namespace AuthenticatorChooser;

public static class I18N {

public enum Key {

SECURITY_KEY,
SMARTPHONE,
WINDOWS,
SIGN_IN_WITH_YOUR_PASSKEY

}

private static readonly FrozenDictionary<Key, IList<string>> STRINGS;

static I18N() {
StringTableResource.Register();

string systemRoot = Environment.GetEnvironmentVariable("SystemRoot") ?? "C:\\Windows";

// #2: CredentialUIBroker.exe runs as the current user
IList<string?> fidoCredProvStrings = getPeFileStrings(Path.Combine(systemRoot, "System32", getUserLocaleId(true), "fidocredprov.dll.mui"), [
(15, 230), // Security key
(15, 231), // Smartphone; also appears in webauthn.dll.mui string table 4 entries 50 and 56
(15, 232) // Windows
]);

// #2: CryptSvc runs as NETWORK SERVICE
IList<string?> webauthnStrings = getPeFileStrings(Path.Combine(systemRoot, "System32", getUserLocaleId(false), "webauthn.dll.mui"), [
(4, 53) // Sign In With Your Passkey title; entry 63 has the same value, not sure which one is used
]);

STRINGS = new Dictionary<Key, IList<string>> {
[Key.SECURITY_KEY] = getUniqueNonNullStrings(Strings.securityKey, fidoCredProvStrings[0]),
[Key.SMARTPHONE] = getUniqueNonNullStrings(Strings.smartphone, fidoCredProvStrings[1]),
[Key.WINDOWS] = getUniqueNonNullStrings(Strings.windows, fidoCredProvStrings[2]),
[Key.SIGN_IN_WITH_YOUR_PASSKEY] = getUniqueNonNullStrings(Strings.signInWithYourPasskey, webauthnStrings[0]),
}.ToFrozenDictionary();

static IList<string> getUniqueNonNullStrings(params string?[] strings) => strings.Compact().Distinct(StringComparer.CurrentCulture).ToList();
}

public static IEnumerable<string> getStrings(Key key) => STRINGS[key];

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

IDictionary<int, StringTable?> stringTableCache = new Dictionary<int, StringTable?>(queries.Count);
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]); // #2: use the table's language, not always English

stringTableCache[stringTableId] = stringTable;
}

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

return results;
} catch (FileNotFoundException) { } catch (DirectoryNotFoundException) { } catch (PortableExecutableImageException) { }

return Enumerable.Repeat<string?>(null, queries.Count).ToList();
}

/// <summary>
/// Get the current locale tag of the user or computer.
/// </summary>
/// <param name="currentUser"><c>true</c> to get the current user's locale, or <c>false</c> to get the locale of the system — specifically, the <c>NETWORK SERVICE</c> user</param>
/// <returns>locale name, such as <c>en-US</c></returns>
public static string getUserLocaleId(bool currentUser) => currentUser
? CultureInfo.CurrentUICulture.Name
: (string) (Registry.GetValue(@"HKEY_USERS\S-1-5-20\Control Panel\International", "LocaleName", null) ?? string.Empty);

}
62 changes: 62 additions & 0 deletions AuthenticatorChooser/Logging.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using NLog;
using NLog.Config;
using NLog.Layouts;
using NLog.MessageTemplates;
using NLog.Targets;
using System.Text;

namespace AuthenticatorChooser;

internal static class Logging {

private static readonly SimpleLayout MESSAGE_FORMAT = new(
" ${level:format=FirstCharacter:lowercase=true} | ${date:format=yyyy-MM-dd HH\\:mm\\:ss.fff} | ${logger:shortName=true:padding=-18} | ${message:withException=true:exceptionSeparator=\n}");

public static void initialize(bool enableFileAppender, string? logFilename) {
logFilename ??= Path.Combine(Path.GetTempPath(), Path.ChangeExtension(nameof(AuthenticatorChooser), ".log"));

LoggingConfiguration logConfig = new();
ServiceRepository services = logConfig.LogFactory.ServiceRepository;
services.RegisterService(typeof(IValueFormatter), new UnfuckedValueFormatter((IValueFormatter) services.GetService(typeof(IValueFormatter))!));

if (enableFileAppender) {
logConfig.AddRule(LogLevel.Trace, LogLevel.Fatal, new FileTarget("fileAppender") {
Layout = MESSAGE_FORMAT,
FileName = logFilename,
CleanupFileName = true
});
}

logConfig.AddRule(LogLevel.Trace, LogLevel.Fatal, new ConsoleTarget("consoleAppender") {
Layout = MESSAGE_FORMAT,
DetectConsoleAvailable = true
});

LogManager.Configuration = logConfig;
}

/// <summary>
/// When logging strings to NLog using structured logging, don't surround them with quotation marks, because it looks stupid
/// </summary>
/// <param name="parent">Built-in <see cref="ValueFormatter"/></param>
private class UnfuckedValueFormatter(IValueFormatter parent): IValueFormatter {

public bool FormatValue(object value, string format, CaptureType captureType, IFormatProvider formatProvider, StringBuilder builder) {
switch (value) {
case string s:
builder.Append(s);
return true;
case StringBuilder s:
builder.Append(s);
return true;
case ReadOnlyMemory<char> s:
builder.Append(s);
return true;
default:
return parent.FormatValue(value, format, captureType, formatProvider, builder);
}
}

}

}
62 changes: 0 additions & 62 deletions AuthenticatorChooser/Program.cs

This file was deleted.

Loading

0 comments on commit 63203c9

Please sign in to comment.