diff --git a/src/XIVLauncher/Accounts/YubiAuth.cs b/src/XIVLauncher/Accounts/YubiAuth.cs new file mode 100644 index 000000000..f10215954 --- /dev/null +++ b/src/XIVLauncher/Accounts/YubiAuth.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Serilog; +using Yubico.YubiKey; +using Yubico.YubiKey.Oath; +using Yubico.PlatformInterop; + +namespace XIVLauncher.Accounts +{ + public class YubiAuth + { + private const string ISSUER = "XIVLauncher"; + private const string ACCOUNT_NAME = "FFXIV"; + private const CredentialType AUTH_TYPE = CredentialType.Totp; + private const CredentialPeriod TIME_PERIOD = CredentialPeriod.Period30; + private const byte NUM_DIGITS = 6; + private static string username = ""; + + private static IYubiKeyDevice _yubiKey; + public YubiKeyDeviceListener DeviceListener; + public YubiAuth() + { + FindYubiDevice(); + DeviceListener = YubiKeyDeviceListener.Instance; + } + + public static void SetUsername(string name) + { + username = name; + } + public static string GetUsername() + { + return username; + } + public string GetAccountName() + { + return ACCOUNT_NAME + "-" + username; + } + + //Generates generic credential + public Credential BuildCredential() + { + var credentialTotp = new Credential + { + Issuer = ISSUER, + AccountName = GetAccountName(), + Type = AUTH_TYPE, + Period = TIME_PERIOD, + Digits = NUM_DIGITS, + }; + return credentialTotp; + } + + //Generates credential to be put onto user's YubiKey + public Credential BuildCredential(string key, bool useTouch) + { + var credentialTotp = new Credential + { + Issuer = ISSUER, + AccountName = GetAccountName(), + Type = AUTH_TYPE, + Period = TIME_PERIOD, + Secret = key, + Digits = NUM_DIGITS, + RequiresTouch = useTouch, + }; + return credentialTotp; + } + + //Finds YubiKey(s) that are plugged into a USB port + public void FindYubiDevice() + { + //Find YubiKey device + try + { + if (_yubiKey == null) + { + IEnumerable keys = YubiKeyDevice.FindByTransport(Transport.UsbSmartCard); + SetYubiDevice(keys.First()); + } + } + catch (Exception ex) when (ex is ArgumentException || ex is InvalidOperationException) + { + Log.Debug("No YubiKey device was detected"); + Log.Debug(ex.ToString()); + } + + } + + public IYubiKeyDevice GetYubiDevice() + { + return _yubiKey; + } + + public void SetYubiDevice(IYubiKeyDevice yubiKeyDevice) + { + _yubiKey = yubiKeyDevice; + } + + //Checks for existing credentials on user's YubiKey that match the XIVLauncher generic credential + public bool CheckForCredential(OathSession session) + { + try + { + IList creds = session.GetCredentials().Where(credential => credential.Issuer == ISSUER && credential.AccountName == GetAccountName()).ToList(); + if (creds != null && creds.Count != 0) + { + return true; + } + return false; + } + catch (SCardException) + { + Log.Error("YubiKey was removed during GET operation."); + return false; + } + } + + //Adds the user's credential onto their YubiKey device + public void CreateEntry(Credential cred) + { + try + { + new OathSession(_yubiKey).AddCredential(cred); + Log.Debug("Successfully created new credential " + cred.Name); + } + catch (SCardException) + { + Log.Error("YubiKey was removed during ADD operation"); + } + + } + + } + + +} diff --git a/src/XIVLauncher/Directory.Build.targets b/src/XIVLauncher/Directory.Build.targets new file mode 100644 index 000000000..ece0b1a9b --- /dev/null +++ b/src/XIVLauncher/Directory.Build.targets @@ -0,0 +1,31 @@ + + + + + + + build + + + Yubico.NativeShims.dll + Always + false + + + + + + + + + build + + + Yubico.NativeShims.dll + Always + false + + + + + diff --git a/src/XIVLauncher/Resources/LICENSE.txt b/src/XIVLauncher/Resources/LICENSE.txt index b7d748730..d70ede45f 100644 --- a/src/XIVLauncher/Resources/LICENSE.txt +++ b/src/XIVLauncher/Resources/LICENSE.txt @@ -724,4 +724,5 @@ System.ValueTuple https://github.com/dotnet/corefx/blob/ aria2 https://github.com/aria2/aria2/blob/master/COPYING Spooky https://github.com/Yortw/Spooky/blob/master/LICENSE AriaNet https://creativecommons.org/licenses/by-nc-sa/3.0/au/ -SharedMemory https://github.com/spazzarama/SharedMemory/blob/master/LICENSE.md \ No newline at end of file +SharedMemory https://github.com/spazzarama/SharedMemory/blob/master/LICENSE.md +Yubico.NET.SDK https://github.com/Yubico/Yubico.NET.SDK/blob/develop/LICENSE.txt diff --git a/src/XIVLauncher/Settings/ILauncherSettingsV3.cs b/src/XIVLauncher/Settings/ILauncherSettingsV3.cs index 0c015d3a2..27908df97 100644 --- a/src/XIVLauncher/Settings/ILauncherSettingsV3.cs +++ b/src/XIVLauncher/Settings/ILauncherSettingsV3.cs @@ -21,6 +21,7 @@ public interface ILauncherSettingsV3 bool InGameAddonEnabled { get; set; } DalamudLoadMethod? InGameAddonLoadMethod { get; set; } bool OtpServerEnabled { get; set; } + bool OtpYubiKeyEnabled { get; set; } ClientLanguage? Language { get; set; } LauncherLanguage? LauncherLanguage { get; set; } string CurrentAccountId { get; set; } diff --git a/src/XIVLauncher/Windows/OtpInputDialog.xaml.cs b/src/XIVLauncher/Windows/OtpInputDialog.xaml.cs index 0d27b80c1..028235870 100644 --- a/src/XIVLauncher/Windows/OtpInputDialog.xaml.cs +++ b/src/XIVLauncher/Windows/OtpInputDialog.xaml.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using System.Windows; using System.Windows.Input; @@ -9,8 +10,13 @@ using System.Windows.Media.Animation; using System.Windows.Threading; using Serilog; +using XIVLauncher.Accounts; using XIVLauncher.Common.Http; using XIVLauncher.Windows.ViewModel; +using Yubico.PlatformInterop; +using Yubico.YubiKey; +using Yubico.YubiKey.Oath; +using Yubico.YubiKey.Oath.Commands; namespace XIVLauncher.Windows { @@ -28,6 +34,9 @@ public partial class OtpInputDialog : Window private OtpListener _otpListener; private bool _ignoreCurrentOtp; + private YubiAuth _yubiAuth; + private readonly object _lock = new(); + private Thread _yubiThread; public OtpInputDialog() { InitializeComponent(); @@ -50,6 +59,17 @@ public OtpInputDialog() _otpListener = new OtpListener("legacy-" + AppUtil.GetAssemblyVersion()); _otpListener.OnOtpReceived += TryAcceptOtp; + if (App.Settings.OtpYubiKeyEnabled) + { + _yubiAuth = new YubiAuth(); + if (_yubiAuth.DeviceListener != null) + { + _yubiAuth.DeviceListener.Arrived += OnYubiKeyArrived; + _yubiAuth.DeviceListener.Removed += OnYubiKeyRemoved; + } + AttemptYubiAuth(); + } + try { // Start Listen @@ -72,6 +92,15 @@ public void Reset() OtpTextBox.Text = ""; OtpTextBox.Focus(); } + public void ResetPrompt() + { + Dispatcher.Invoke(() => + { + OtpInputPrompt.Text = ViewModel.OtpInputPromptLoc; + OtpInputPrompt.Foreground = _otpInputPromptDefaultBrush; + OtpTextBox.Focus(); + }); + } public void IgnoreCurrentResult(string reason) { @@ -114,6 +143,7 @@ public void TryAcceptOtp(string otp) else { _otpListener?.Stop(); + CleanupYubi(); DialogResult = true; Hide(); } @@ -124,9 +154,19 @@ private void Cancel() { OnResult?.Invoke(null); _otpListener?.Stop(); + CleanupYubi(); DialogResult = false; Hide(); } + private void CleanupYubi() + { + if (_yubiAuth != null) + { + _yubiAuth.DeviceListener.Arrived -= OnYubiKeyArrived; + _yubiAuth.DeviceListener.Removed -= OnYubiKeyRemoved; + _yubiThread?.Abort(); + } + } private void OtpInputDialog_OnMouseMove(object sender, MouseEventArgs e) { @@ -175,6 +215,126 @@ private void PasteButton_OnClick(object sender, RoutedEventArgs e) this.OtpTextBox.Text = Clipboard.GetText(); TryAcceptOtp(this.OtpTextBox.Text); } + private void OnYubiKeyArrived(object sender, YubiKeyDeviceEventArgs e) + { + Log.Debug("YubiKey found! " + e.Device.ToString()); + _yubiAuth.SetYubiDevice(e.Device); + AttemptYubiAuth(); + } + + private void OnYubiKeyRemoved(object sender, YubiKeyDeviceEventArgs e) + { + Log.Debug("YubiKey removed!"); + _yubiAuth.SetYubiDevice(null); + ResetPrompt(); + } + + private void AttemptYubiAuth() + { + if (_yubiAuth.GetYubiDevice() == null) + { + return; + } + + OathSession session = new OathSession(_yubiAuth.GetYubiDevice()); + var totp = _yubiAuth.BuildCredential(); + + try + { + //Set prompt respectively + Dispatcher.Invoke(() => + { + if (_yubiAuth.CheckForCredential(session) == true) + { + OtpInputPrompt.Text = ViewModel.OtpInputPromptYubiLoc; + OtpInputPrompt.Foreground = Brushes.LightGreen; + Storyboard myStoryboard = (Storyboard)OtpInputPrompt.Resources["InvalidShake"]; + Storyboard.SetTarget(myStoryboard.Children.ElementAt(0), OtpInputPrompt); + myStoryboard.Begin(); + OtpTextBox.Focus(); + } + else + { + OtpInputPrompt.Text = ViewModel.OtpInputPromptYubiBadLoc; + OtpInputPrompt.Foreground = Brushes.Red; + Storyboard myStoryboard = (Storyboard)OtpInputPrompt.Resources["InvalidShake"]; + Storyboard.SetTarget(myStoryboard.Children.ElementAt(0), OtpInputPrompt); + myStoryboard.Begin(); + OtpTextBox.Focus(); + throw new InvalidOperationException("Unable to find valid YubiKey credential."); + } + }); + + } + catch (SCardException) + { + Log.Error("YubiKey was removed while performing operation."); + return; + } + + catch (InvalidOperationException e) + { + Log.Error(e.Message); + return; + } + + byte retries = 0; + _yubiThread = new Thread(() => + { + //Handle touch-based authentication, gives user three attempts to touch YubiKey + //Attempts are approximately 15 seconds by defualt + while (retries < 3 && _yubiAuth.GetYubiDevice() != null) + { + CalculateCredentialResponse ccr = null; + try + { + //Attempts to generate otp and then login + CalculateCredentialCommand ccd = new CalculateCredentialCommand(totp, ResponseFormat.Truncated); + ccr = session.Connection.SendCommand(ccd); + + Log.Debug("Status: " + ccr.Status); + Log.Debug("Status Message: " + ccr.StatusMessage); + Log.Debug("Data: " + ccr.GetData().Value); + TryAcceptOtp(ccr.GetData().Value); + break; + } + catch (InvalidOperationException ex) + { + Log.Debug(ex.Message); + + retries++; + + //Handle authentication timeout + if (ccr != null) + { + Log.Debug("Status: " + ccr.Status); + Log.Debug("Status Message: " + ccr.StatusMessage); + } + + if (retries == 3) + { + Dispatcher.Invoke(() => + { + OtpInputPrompt.Text = ViewModel.OtpInputPromptYubiTimeoutLoc; + OtpInputPrompt.Foreground = Brushes.Red; + Storyboard myStoryboard = (Storyboard)OtpInputPrompt.Resources["InvalidShake"]; + Storyboard.SetTarget(myStoryboard.Children.ElementAt(0), OtpInputPrompt); + myStoryboard.Begin(); + OtpTextBox.Focus(); + }); + break; + } + } + catch (SCardException) + { + Log.Error("YubiKey was removed during authentication attempt."); + break; + } + } + }); + _yubiThread.Start(); + + } public void OpenShortcutInfo_MouseUp(object sender, RoutedEventArgs e) { diff --git a/src/XIVLauncher/Windows/SecurityKeySetupDialog.xaml b/src/XIVLauncher/Windows/SecurityKeySetupDialog.xaml new file mode 100644 index 000000000..6d2199238 --- /dev/null +++ b/src/XIVLauncher/Windows/SecurityKeySetupDialog.xaml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/XIVLauncher/Windows/SettingsControl.xaml.cs b/src/XIVLauncher/Windows/SettingsControl.xaml.cs index 82c7b053f..1efbf224b 100644 --- a/src/XIVLauncher/Windows/SettingsControl.xaml.cs +++ b/src/XIVLauncher/Windows/SettingsControl.xaml.cs @@ -87,6 +87,12 @@ public void ReloadSettings() OtpServerCheckBox.IsChecked = App.Settings.OtpServerEnabled; + if (OtpServerCheckBox.IsChecked == true) + OtpYubiKeyCheckBox.IsChecked = App.Settings.OtpYubiKeyEnabled; + else + OtpYubiKeyCheckBox.IsChecked = false; + + LaunchArgsTextBox.Text = App.Settings.AdditionalLaunchArgs; DpiAwarenessComboBox.SelectedIndex = (int) App.Settings.DpiAwareness.GetValueOrDefault(DpiAwareness.Unaware); @@ -137,6 +143,8 @@ private void AcceptButton_Click(object sender, RoutedEventArgs e) App.Settings.OtpServerEnabled = OtpServerCheckBox.IsChecked == true; + App.Settings.OtpYubiKeyEnabled = OtpYubiKeyCheckBox.IsChecked == true; + App.Settings.AdditionalLaunchArgs = LaunchArgsTextBox.Text; App.Settings.DpiAwareness = (DpiAwareness) DpiAwarenessComboBox.SelectedIndex; @@ -435,6 +443,23 @@ private void LearnMoreButton_OnClick(object sender, RoutedEventArgs e) { PlatformHelpers.OpenBrowser("https://goatcorp.github.io/faq/mobile_otp"); } + private void LearnMoreYubiKeyButton_OnClick(object sender, RoutedEventArgs e) + { + PlatformHelpers.OpenBrowser("https://goatcorp.github.io/faq/yubikey"); + } + private void YubiKeySetupAuthButton_OnClick(object sender, RoutedEventArgs e) + { + var authKey = string.Empty; + authKey = SecurityKeySetupDialog.AskForKey((securityKeySetupDialog, result) => + { + if (securityKeySetupDialog.DialogResult == true) + { + CustomMessageBox.Show(Loc.Localize("SetupAuthSuccess", $"YubiKey account \"{result}\" has been successfully created.\nYou may now use this YubiKey alongside XIVLauncher."), + "XIVLauncher - YubiKey Setup", image: MessageBoxImage.Information, showDiscordLink: false, showHelpLinks: false); + } + }, Window.GetWindow(this)); + + } private void IsFreeTrialCheckbox_OnClick(object sender, RoutedEventArgs e) { diff --git a/src/XIVLauncher/Windows/ViewModel/MainWindowViewModel.cs b/src/XIVLauncher/Windows/ViewModel/MainWindowViewModel.cs index 33e49959b..ec7605097 100644 --- a/src/XIVLauncher/Windows/ViewModel/MainWindowViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/MainWindowViewModel.cs @@ -219,6 +219,7 @@ private async Task Login(string username, string password, bool isOtp, bool isSt } username = username.Replace(" ", string.Empty); // Remove whitespace + YubiAuth.SetUsername(username); if (Repository.Ffxiv.GetVer(App.Settings.GamePath) == Constants.BASE_GAME_VERSION && App.Settings.UniqueIdCacheEnabled) diff --git a/src/XIVLauncher/Windows/ViewModel/OtpInputDialogViewModel.cs b/src/XIVLauncher/Windows/ViewModel/OtpInputDialogViewModel.cs index 625eca5f6..2e7febbcb 100644 --- a/src/XIVLauncher/Windows/ViewModel/OtpInputDialogViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/OtpInputDialogViewModel.cs @@ -17,6 +17,9 @@ private void SetupLoc() OtpOneClickHintLoc = Loc.Localize("OtpOneClickHint", "Or use the app!\r\nClick here to learn more!"); OtpInputPromptBadLoc = Loc.Localize("OtpInputPromptBad", "Enter a valid OTP key.\nIt is 6 digits long."); PasteButtonLoc = Loc.Localize("PasteButton", "Click here to paste from the clipboard."); + OtpInputPromptYubiLoc = Loc.Localize("OtpInputPromptYubi", "Found a valid Yubikey, touch it to login."); + OtpInputPromptYubiBadLoc = Loc.Localize("OtpInputPromptYubiBad", "Yubikey has not been setup yet.\nCheck Settings to complete setup."); + OtpInputPromptYubiTimeoutLoc = Loc.Localize("OtpInputPromptYubiTimeout", "YubiKey timed out\nEnter your OTP or reinsert the YubiKey."); } public string OtpInputPromptLoc { get; private set; } @@ -25,5 +28,8 @@ private void SetupLoc() public string OtpOneClickHintLoc { get; private set; } public string OtpInputPromptBadLoc { get; private set; } public string PasteButtonLoc { get; private set; } + public string OtpInputPromptYubiLoc { get; private set; } + public string OtpInputPromptYubiBadLoc { get; private set; } + public string OtpInputPromptYubiTimeoutLoc { get; private set; } } } diff --git a/src/XIVLauncher/Windows/ViewModel/SecurityKeySetupDialogViewModel.cs b/src/XIVLauncher/Windows/ViewModel/SecurityKeySetupDialogViewModel.cs new file mode 100644 index 000000000..cedd2e335 --- /dev/null +++ b/src/XIVLauncher/Windows/ViewModel/SecurityKeySetupDialogViewModel.cs @@ -0,0 +1,35 @@ +using CheapLoc; + +namespace XIVLauncher.Windows.ViewModel +{ + class SecurityKeySetupDialogViewModel + { + public SecurityKeySetupDialogViewModel() + { + SetupLoc(); + } + + private void SetupLoc() + { + KeySetupInputPromptInsertLoc = Loc.Localize("SetupInputPromptInsert", "Please insert your YubiKey now."); + KeySetupInputPromptYubiLoc = Loc.Localize("SetupInputPromptYubi", "Found a Yubikey.\nPlease enter your Authentication Key."); + CancelWithShortcutLoc = Loc.Localize("CancelWithShortcut", "_Cancel"); + OkLoc = Loc.Localize("OK", "OK"); + KeySetupOnClickHintLoc = Loc.Localize("SetupOnClickHint", "Don't know what this is?\n Check out the FAQ!"); + KeySetupInputPromptBadLoc = Loc.Localize("SetupInputPromptBad", "Enter a valid Authentication Key.\nKey needs to be 32 characters long."); + KeySetupCheckBoxLoc = Loc.Localize("SetupCheckBox", "Require Touch?"); + KeySetupTooltipLoc = Loc.Localize("SetupTooltip", "If checked, makes your YubiKey device require touch in order to authenticate."); + KeySetupUsernameLoc = Loc.Localize("SetupUsername", "Unable to verify username\nPlease login using XIVLauncher at least once."); + } + public string KeySetupInputPromptInsertLoc { get; private set; } + public string KeySetupInputPromptYubiLoc { get; private set; } + public string CancelWithShortcutLoc { get; private set; } + public string OkLoc { get; private set; } + public string KeySetupOnClickHintLoc { get; private set; } + public string KeySetupInputPromptBadLoc { get; private set; } + public string KeySetupCheckBoxLoc { get; private set; } + public string KeySetupTooltipLoc { get; private set; } + public string KeySetupUsernameLoc { get; private set; } + + } +} diff --git a/src/XIVLauncher/Windows/ViewModel/SettingsControlViewModel.cs b/src/XIVLauncher/Windows/ViewModel/SettingsControlViewModel.cs index 4378119e4..5550d6594 100644 --- a/src/XIVLauncher/Windows/ViewModel/SettingsControlViewModel.cs +++ b/src/XIVLauncher/Windows/ViewModel/SettingsControlViewModel.cs @@ -72,6 +72,10 @@ private void SetupLoc() OtpServerTooltipLoc = Loc.Localize("OtpServerTooltip", "This will allow you to send your OTP code to XIVLauncher directly from your phone.\nClick \"Learn more\" to see how to set this up."); LearnMoreLoc = Loc.Localize("LearnMore", "Learn More"); OtpLearnMoreTooltipLoc = Loc.Localize("OtpLearnMoreTooltipLoc", "Open a guide in your web browser."); + OtpYubiKeyCheckBoxLoc = Loc.Localize("OtpYubikeyCheckBox", "Enable YubiKey-based authentication"); + OtpYubiKeyTooltipLoc = Loc.Localize("OtpYubikeyTooltip", "This will allow you to input your OTP code to XIVLauncher directly from touching your YubiKey.\nClick \"Learn more\" to see how to set this up."); + OtpSetupAuthLoc = Loc.Localize("OtpSetupAuth", "Setup Auth"); + OtpSetupAuthTooltipLoc = Loc.Localize("OtpSetupAuthTooltip", "Sets up a physical YubiKey device."); AdditionalArgumentsLoc = Loc.Localize("AdditionalArguments", "Additional launch arguments"); ChooseDpiAwarenessLoc = Loc.Localize("ChooseDpiAwareness", "Game DPI Awareness"); DpiAwarenessAwareLoc = Loc.Localize("DpiAwarenessAware", "Aware"); @@ -179,6 +183,10 @@ private void SetupLoc() public string OtpServerTooltipLoc { get; private set; } public string LearnMoreLoc { get; private set; } public string OtpLearnMoreTooltipLoc { get; private set; } + public string OtpYubiKeyCheckBoxLoc { get; private set; } + public string OtpYubiKeyTooltipLoc { get; private set; } + public string OtpSetupAuthLoc { get; private set; } + public string OtpSetupAuthTooltipLoc { get; private set; } public string AdditionalArgumentsLoc { get; private set; } public string ChooseDpiAwarenessLoc { get; private set; } public string ChooseDpiAwarenessHintLoc { get; private set; } diff --git a/src/XIVLauncher/XIVLauncher.csproj b/src/XIVLauncher/XIVLauncher.csproj index 07899d518..f85318634 100644 --- a/src/XIVLauncher/XIVLauncher.csproj +++ b/src/XIVLauncher/XIVLauncher.csproj @@ -86,6 +86,7 @@ +