diff --git a/.gitignore b/.gitignore index 8772e0fa..ab1c1454 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ TestResults *.user *.sln.docstates .vs +.idea # Build results [Dd]ebug/ @@ -117,4 +118,4 @@ protobuf/ cryptopp/ # misc -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/DepotDownloader/DownloadConfig.cs b/DepotDownloader/DownloadConfig.cs index 1022aac4..77de143c 100644 --- a/DepotDownloader/DownloadConfig.cs +++ b/DepotDownloader/DownloadConfig.cs @@ -28,5 +28,6 @@ namespace DepotDownloader public uint? LoginID { get; set; } public bool UseQrCode { get; set; } + public string TotpKey { get; set; } } } diff --git a/DepotDownloader/Program.cs b/DepotDownloader/Program.cs index eb396051..a9db5903 100644 --- a/DepotDownloader/Program.cs +++ b/DepotDownloader/Program.cs @@ -46,6 +46,7 @@ namespace DepotDownloader var username = GetParameter(args, "-username") ?? GetParameter(args, "-user"); var password = GetParameter(args, "-password") ?? GetParameter(args, "-pass"); + ContentDownloader.Config.TotpKey = GetParameter(args, "-totp-key"); ContentDownloader.Config.RememberPassword = HasParameter(args, "-remember-password"); ContentDownloader.Config.UseQrCode = HasParameter(args, "-qr"); @@ -401,6 +402,7 @@ namespace DepotDownloader Console.WriteLine("\t-max-servers <#>\t\t- maximum number of content servers to use. (default: 20)."); Console.WriteLine("\t-max-downloads <#>\t\t- maximum number of chunks to download concurrently. (default: 8)."); Console.WriteLine("\t-loginid <#>\t\t- a unique 32-bit integer Steam LogonID in decimal, required if running multiple instances of DepotDownloader concurrently."); + Console.WriteLine("\t-totp-key \t\t- the TOTP authenticator key for the steam account, to automatically generate 2FA codes."); } } } diff --git a/DepotDownloader/Steam3Session.cs b/DepotDownloader/Steam3Session.cs index 206b3b56..1c8171ec 100644 --- a/DepotDownloader/Steam3Session.cs +++ b/DepotDownloader/Steam3Session.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using QRCoder; @@ -508,12 +509,12 @@ namespace DepotDownloader { try { - authSession = await steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new SteamKit2.Authentication.AuthSessionDetails + authSession = await steamClient.Authentication.BeginAuthSessionViaCredentialsAsync(new AuthSessionDetails { Username = logonDetails.Username, Password = logonDetails.Password, IsPersistentSession = ContentDownloader.Config.RememberPassword, - Authenticator = new UserConsoleAuthenticator(), + Authenticator = new TotpAuthenticator(ContentDownloader.Config.TotpKey, new UserConsoleAuthenticator()) }); } catch (TaskCanceledException) diff --git a/DepotDownloader/TotpAuthenticator.cs b/DepotDownloader/TotpAuthenticator.cs new file mode 100644 index 00000000..6ec8424b --- /dev/null +++ b/DepotDownloader/TotpAuthenticator.cs @@ -0,0 +1,91 @@ +using System; +using System.Security.Cryptography; +using System.Threading.Tasks; +using SteamKit2.Authentication; + +namespace DepotDownloader; + +/// +/// Implementation of that uses a TOTP Authenticator key to generate TOTP verification codes. +/// Falls back to the provided fallback authenticator. +/// +public class TotpAuthenticator : IAuthenticator +{ + private readonly string _totpKey; + private readonly IAuthenticator _fallbackAuthenticator; + + public TotpAuthenticator(string totpKey, IAuthenticator fallbackAuthenticator) + { + _totpKey = totpKey; + _fallbackAuthenticator = fallbackAuthenticator; + } + + /// + public Task GetDeviceCodeAsync(bool previousCodeWasIncorrect) + { + if (previousCodeWasIncorrect) + { + return _fallbackAuthenticator.GetDeviceCodeAsync(true); + } + + var deviceCode = GetDeviceCode(_totpKey); + return deviceCode != null ? Task.FromResult(deviceCode) : _fallbackAuthenticator.GetDeviceCodeAsync(false); + } + + /// + public Task GetEmailCodeAsync(string email, bool previousCodeWasIncorrect) + { + return _fallbackAuthenticator.GetEmailCodeAsync(email, previousCodeWasIncorrect); + } + + /// + public Task AcceptDeviceConfirmationAsync() + { + return _fallbackAuthenticator.AcceptDeviceConfirmationAsync(); + } + + // https://github.com/bitwarden/mobile/blob/7a65bf7fd7b44424073201c2c574d45b64b9ec9d/src/Core/Services/TotpService.cs + private static string GetDeviceCode(string key) + { + const int Digits = 5; + const int TotpDefaultTimer = 30; + const string SteamChars = "23456789BCDFGHJKMNPQRTVWXY"; + + if (string.IsNullOrWhiteSpace(key)) + { + return null; + } + + var keyBytes = Util.DecodeBase32String(key); + if (keyBytes == null || keyBytes.Length == 0) + { + return null; + } + var time = DateTimeOffset.Now.ToUnixTimeSeconds() / TotpDefaultTimer; + var timeBytes = BitConverter.GetBytes(time); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(timeBytes, 0, timeBytes.Length); + } + + var hash = new HMACSHA1(keyBytes).ComputeHash(timeBytes); + + if (hash.Length == 0) + { + return null; + } + + var offset = hash[^1] & 0xf; + var binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | + ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); + + var otp = string.Empty; + var fullCode = binary & 0x7fffffff; + for (var i = 0; i < Digits; i++) + { + otp += SteamChars[fullCode % SteamChars.Length]; + fullCode = (int)Math.Truncate(fullCode / (double)SteamChars.Length); + } + return otp; + } +} diff --git a/DepotDownloader/Util.cs b/DepotDownloader/Util.cs index f35a43bf..2d0398ed 100644 --- a/DepotDownloader/Util.cs +++ b/DepotDownloader/Util.cs @@ -144,6 +144,69 @@ namespace DepotDownloader ).ToString(); } + public static byte[] DecodeBase32String(string input) + { + const string Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + if (input == null) + { + throw new ArgumentNullException(nameof(input)); + } + + input = input.ToUpperInvariant(); + var cleanedInput = string.Empty; + foreach (var c in input) + { + if (Base32Chars.IndexOf(c) < 0) + { + continue; + } + + cleanedInput += c; + } + + input = cleanedInput; + if (input.Length == 0) + { + return Array.Empty(); + } + + var output = new byte[input.Length * 5 / 8]; + var bitIndex = 0; + var inputIndex = 0; + var outputBits = 0; + var outputIndex = 0; + + while (outputIndex < output.Length) + { + var byteIndex = Base32Chars.IndexOf(input[inputIndex]); + if (byteIndex < 0) + { + throw new FormatException(); + } + + var bits = Math.Min(5 - bitIndex, 8 - outputBits); + output[outputIndex] <<= bits; + output[outputIndex] |= (byte)(byteIndex >> (5 - (bitIndex + bits))); + + bitIndex += bits; + if (bitIndex >= 5) + { + inputIndex++; + bitIndex = 0; + } + + outputBits += bits; + if (outputBits >= 8) + { + outputIndex++; + outputBits = 0; + } + } + + return output; + } + public static async Task InvokeAsync(IEnumerable> taskFactories, int maxDegreeOfParallelism) { if (taskFactories == null) throw new ArgumentNullException(nameof(taskFactories)); diff --git a/README.md b/README.md index 7bb3c666..c0a3d013 100644 --- a/README.md +++ b/README.md @@ -27,32 +27,33 @@ For example: `dotnet DepotDownloader.dll -app 730 -ugc 770604181014286929` ## Parameters -Parameter | Description ---------- | ----------- --app \<#> | the AppID to download. --depot \<#> | the DepotID to download. --manifest \ | manifest id of content to download (requires -depot, default: current for branch). --ugc \<#> | the UGC ID to download. --beta \ | download from specified branch if available (default: Public). --betapassword \ | branch password if applicable. --all-platforms | downloads all platform-specific depots when -app is used. --os \ | the operating system for which to download the game (windows, macos or linux, default: OS the program is currently running on) --osarch \ | the architecture for which to download the game (32 or 64, default: the host's architecture) --all-languages | download all language-specific depots when -app is used. --language \ | the language for which to download the game (default: english) --lowviolence | download low violence depots when -app is used. --pubfile \<#> | the PublishedFileId to download. (Will automatically resolve to UGC id) --username \ | the username of the account to login to for restricted content. --password \ | the password of the account to login to for restricted content. --remember-password | if set, remember the password for subsequent logins of this user. (Use -username -remember-password as login credentials) --dir \ | the directory in which to place downloaded files. --filelist \ | a list of files to download (from the manifest). Prefix file path with `regex:` if you want to match with regex. --validate | Include checksum verification of files already downloaded --manifest-only | downloads a human readable manifest for any depots that would be downloaded. --cellid \<#> | the overridden CellID of the content server to download from. --max-servers \<#> | maximum number of content servers to use. (default: 20). --max-downloads \<#> | maximum number of chunks to download concurrently. (default: 8). --loginid \<#> | a unique 32-bit integer Steam LogonID in decimal, required if running multiple instances of DepotDownloader concurrently. +| Parameter | Description | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| -app \<#> | the AppID to download. | +| -depot \<#> | the DepotID to download. | +| -manifest \ | manifest id of content to download (requires -depot, default: current for branch). | +| -ugc \<#> | the UGC ID to download. | +| -beta \ | download from specified branch if available (default: Public). | +| -betapassword \ | branch password if applicable. | +| -all-platforms | downloads all platform-specific depots when -app is used. | +| -os \ | the operating system for which to download the game (windows, macos or linux, default: OS the program is currently running on) | +| -osarch \ | the architecture for which to download the game (32 or 64, default: the host's architecture) | +| -all-languages | download all language-specific depots when -app is used. | +| -language \ | the language for which to download the game (default: english) | +| -lowviolence | download low violence depots when -app is used. | +| -pubfile \<#> | the PublishedFileId to download. (Will automatically resolve to UGC id) | +| -username \ | the username of the account to login to for restricted content. | +| -password \ | the password of the account to login to for restricted content. | +| -remember-password | if set, remember the password for subsequent logins of this user. (Use -username -remember-password as login credentials) | +| -dir \ | the directory in which to place downloaded files. | +| -filelist \ | a list of files to download (from the manifest). Prefix file path with `regex:` if you want to match with regex. | +| -validate | Include checksum verification of files already downloaded | +| -manifest-only | downloads a human readable manifest for any depots that would be downloaded. | +| -cellid \<#> | the overridden CellID of the content server to download from. | +| -max-servers \<#> | maximum number of content servers to use. (default: 20). | +| -max-downloads \<#> | maximum number of chunks to download concurrently. (default: 8). | +| -loginid \<#> | a unique 32-bit integer Steam LogonID in decimal, required if running multiple instances of DepotDownloader concurrently. | +| -totp-key \ | the TOTP authenticator key for the steam account, to automatically generate 2FA codes. | ## Frequently Asked Questions