diff --git a/bitwarden-mobile.sln b/bitwarden-mobile.sln index 5691f2367..1908dbc65 100644 --- a/bitwarden-mobile.sln +++ b/bitwarden-mobile.sln @@ -14,6 +14,9 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Playground", "test\Playground\Playground.csproj", "{9C8DA5A8-904D-466F-B9B0-1A4AB5A9AFC3}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D10CA4A9-F866-40E1-B658-F69051236C71}" + ProjectSection(SolutionItems) = preProject + src\Core\Services\ITotpService.cs = src\Core\Services\ITotpService.cs + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{8904C536-C67D-420F-9971-51B26574C3AA}" EndProject diff --git a/src/Core/Abstractions/ITotpService.cs b/src/Core/Abstractions/ITotpService.cs new file mode 100644 index 000000000..0de407e3e --- /dev/null +++ b/src/Core/Abstractions/ITotpService.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace Bit.Core.Abstractions +{ + public interface ITotpService + { + Task GetCodeAsync(string key); + int GetTimeInterval(string key); + Task IsAutoCopyEnabledAsync(); + } +} \ No newline at end of file diff --git a/src/Core/Constants.cs b/src/Core/Constants.cs index 1047890ce..7b81571b2 100644 --- a/src/Core/Constants.cs +++ b/src/Core/Constants.cs @@ -7,5 +7,6 @@ public static string LockOptionKey = "lockOption"; public static string PinProtectedKey = "pinProtectedKey"; public static string DefaultUriMatch = "defaultUriMatch"; + public static string DisableAutoTotpCopyKey = "disableAutoTotpCopy"; } } diff --git a/src/Core/Services/TotpService.cs b/src/Core/Services/TotpService.cs new file mode 100644 index 000000000..21c006539 --- /dev/null +++ b/src/Core/Services/TotpService.cs @@ -0,0 +1,142 @@ +using Bit.Core.Abstractions; +using Bit.Core.Enums; +using Bit.Core.Utilities; +using System; +using System.Threading.Tasks; + +namespace Bit.Core.Services +{ + public class TotpService : ITotpService + { + private const string SteamChars = "23456789BCDFGHJKMNPQRTVWXY"; + + private readonly IStorageService _storageService; + private readonly ICryptoFunctionService _cryptoFunctionService; + + public TotpService( + IStorageService storageService, + ICryptoFunctionService cryptoFunctionService) + { + _storageService = storageService; + _cryptoFunctionService = cryptoFunctionService; + } + + public async Task GetCodeAsync(string key) + { + if(string.IsNullOrWhiteSpace(key)) + { + return null; + } + var period = 30; + var alg = CryptoHashAlgorithm.Sha1; + var digits = 6; + var keyB32 = key; + + var isOtpAuth = key?.ToLowerInvariant().StartsWith("otpauth://") ?? false; + var isSteamAuth = key?.ToLowerInvariant().StartsWith("steam://") ?? false; + if(isOtpAuth) + { + var qsParams = CoreHelpers.GetQueryParams(key); + if(qsParams.ContainsKey("digits") && qsParams["digits"] != null && + int.TryParse(qsParams["digits"].Trim(), out var digitParam)) + { + if(digitParam > 10) + { + digits = 10; + } + else if(digitParam > 0) + { + digits = digitParam; + } + } + if(qsParams.ContainsKey("period") && qsParams["period"] != null && + int.TryParse(qsParams["period"].Trim(), out var periodParam) && periodParam > 0) + { + period = periodParam; + } + if(qsParams.ContainsKey("secret") && qsParams["secret"] != null) + { + keyB32 = qsParams["secret"]; + } + if(qsParams.ContainsKey("algorithm") && qsParams["algorithm"] != null) + { + var algParam = qsParams["algorithm"].ToLowerInvariant(); + if(algParam == "sha256") + { + alg = CryptoHashAlgorithm.Sha256; + } + else if(algParam == "sha512") + { + alg = CryptoHashAlgorithm.Sha512; + } + } + } + else if(isSteamAuth) + { + digits = 5; + keyB32 = key.Substring(8); + } + + var keyBytes = Base32.FromBase32(keyB32); + if(keyBytes == null || keyBytes.Length == 0) + { + return null; + } + var now = CoreHelpers.EpocUtcNow() / 1000; + var time = now / period; + var timeBytes = BitConverter.GetBytes(time); + if(BitConverter.IsLittleEndian) + { + Array.Reverse(timeBytes, 0, timeBytes.Length); + } + + var hash = await _cryptoFunctionService.HmacAsync(timeBytes, keyBytes, alg); + if(hash.Length == 0) + { + return null; + } + + var offset = (hash[hash.Length - 1] & 0xf); + var binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16) | + ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff); + + string otp = string.Empty; + if(isSteamAuth) + { + var fullCode = binary & 0x7fffffff; + for(var i = 0; i < digits; i++) + { + otp += SteamChars[fullCode % SteamChars.Length]; + fullCode = (int)Math.Truncate(fullCode / (double)SteamChars.Length); + } + } + else + { + var rawOtp = binary % (int)Math.Pow(10, digits); + otp = rawOtp.ToString().PadLeft(digits, '0'); + } + return otp; + } + + public int GetTimeInterval(string key) + { + var period = 30; + if(key != null && key.ToLowerInvariant().StartsWith("otpauth://")) + { + var qsParams = CoreHelpers.GetQueryParams(key); + if(qsParams.ContainsKey("period") && qsParams["period"] != null && + int.TryParse(qsParams["period"].Trim(), out var periodParam) && periodParam > 0) + { + period = periodParam; + } + } + return period; + } + + public async Task IsAutoCopyEnabledAsync() + { + var disabled = await _storageService.GetAsync(Constants.DisableAutoTotpCopyKey); + return !disabled.GetValueOrDefault(); + } + } +} diff --git a/src/Core/Utilities/Base32.cs b/src/Core/Utilities/Base32.cs new file mode 100644 index 000000000..310f026f1 --- /dev/null +++ b/src/Core/Utilities/Base32.cs @@ -0,0 +1,72 @@ +using System; + +namespace Bit.Core.Utilities +{ + // ref: https://github.com/aspnet/Identity/blob/dev/src/Microsoft.Extensions.Identity.Core/Base32.cs + // with some modifications for cleaning input + public static class Base32 + { + private static readonly string _base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + public static byte[] FromBase32(string input) + { + 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 new byte[0]; + } + + 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; + } + } +} diff --git a/src/Core/Utilities/CoreHelpers.cs b/src/Core/Utilities/CoreHelpers.cs index e09051f81..30770289c 100644 --- a/src/Core/Utilities/CoreHelpers.cs +++ b/src/Core/Utilities/CoreHelpers.cs @@ -19,6 +19,11 @@ namespace Bit.Core.Utilities public static readonly DateTime Epoc = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + public static long EpocUtcNow() + { + return (long)(DateTime.UtcNow - Epoc).TotalMilliseconds; + } + public static bool InDebugMode() { #if DEBUG @@ -150,5 +155,29 @@ namespace Bit.Core.Utilities } return null; } + + public static Dictionary GetQueryParams(string urlString) + { + var dict = new Dictionary(); + if(!Uri.TryCreate(urlString, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Query)) + { + return dict; + } + var pairs = uri.Query.Substring(1).Split('&'); + foreach(var pair in pairs) + { + var parts = pair.Split('='); + if(parts.Length < 1) + { + continue; + } + var key = System.Net.WebUtility.UrlDecode(parts[0]).ToLower(); + if(!dict.ContainsKey(key)) + { + dict.Add(key, parts[1] == null ? string.Empty : System.Net.WebUtility.UrlDecode(parts[1])); + } + } + return dict; + } } }