diff --git a/KeeTrayTOTP.Tests/KeyUriTests.cs b/KeeTrayTOTP.Tests/KeyUriTests.cs new file mode 100644 index 0000000..b2b3359 --- /dev/null +++ b/KeeTrayTOTP.Tests/KeyUriTests.cs @@ -0,0 +1,178 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace KeeTrayTOTP.Tests +{ + [TestClass] + public class KeyUriTests + { + private const string MinimalKeyUri = "otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co"; + + [TestMethod] + public void Ctor_ShouldInitializeValidTotpKeyUri() + { + var uri = new Uri("otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"); + + var keyUri = new KeyUri(uri); + keyUri.Type.Should().Be("totp"); + keyUri.Secret.Should().Be("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"); + keyUri.Issuer.Should().Be("ACME Co"); + keyUri.Algorithm.Should().Be("SHA1"); + keyUri.Digits.Should().Be(6); + keyUri.Period.Should().Be(30); + } + + [TestMethod] + public void Ctor_ShouldValidateUri() + { + Action act = () => new KeyUri(null); + + act.Should().ThrowExactly().WithMessage("Uri should not be null.*"); + } + + [TestMethod] + public void Ctor_ShouldValidateScheme() + { + var uri = new Uri("xxxotpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"); + + Action act = () => new KeyUri(uri); + + act.Should().ThrowExactly().WithMessage("Uri scheme must be otpauth.*"); + } + + [TestMethod] + public void Ctor_ShouldValidateType() + { + var uri = new Uri("otpauth://hotp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30"); + + Action act = () => new KeyUri(uri); + + act.Should().ThrowExactly().WithMessage("Only totp is supported.*"); + } + + [TestMethod] + public void Ctor_ShouldValidateSecret() + { + var uri = new Uri("otpauth://totp/ACME%20Co:john.doe@email.com?secret=!!HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ"); + + Action act = () => new KeyUri(uri); + + act.Should().ThrowExactly().WithMessage("Secret is not valid base32.*"); + } + + [TestMethod] + public void MinimalUrl_ShouldDefaultAlgorithmToSha1() + { + var uri = new Uri(MinimalKeyUri); + + var keyUri = new KeyUri(uri); + keyUri.Algorithm.Should().Be("SHA1"); + } + + [TestMethod] + public void GetUri_ShouldContainNonDefaultParameters() + { + var uri = new Uri(MinimalKeyUri); + + var keyUri = new KeyUri(uri); + keyUri.Algorithm = "SHA256"; + keyUri.Digits = 8; + keyUri.Period = 60; + + var absoluteUri = keyUri.GetUri().AbsoluteUri; + absoluteUri.Should().Contain("period=60"); + absoluteUri.Should().Contain("digits=8"); + absoluteUri.Should().Contain("algorithm=SHA256"); + } + + + [TestMethod] + public void GetUri_ShouldContainDefaultParameters() + { + var uri = new Uri(MinimalKeyUri); + + var keyUri = new KeyUri(uri); + keyUri.Algorithm = "SHA1"; + keyUri.Digits = 6; + keyUri.Period = 30; + + var absoluteUri = keyUri.GetUri().AbsoluteUri; + absoluteUri.Should().NotContain("period="); + absoluteUri.Should().NotContain("digits="); + absoluteUri.Should().NotContain("algorithm="); + } + + [TestMethod] + public void MinimalUrl_ShouldDefaultPeriodTo30() + { + var uri = new Uri(MinimalKeyUri); + + var keyUri = new KeyUri(uri); + keyUri.Period.Should().Be(30); + } + + [TestMethod] + public void MinimalUrl_ShouldDefaultDigitsTo6() + { + var uri = new Uri(MinimalKeyUri); + + var keyUri = new KeyUri(uri); + keyUri.Digits.Should().Be(6); + } + + [TestMethod] + public void TestFullKeyUri() + { + var model = new KeyUri(new Uri("otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")); + var modelUri = model.GetUri(); + var generatedUri = new KeyUri(modelUri); + + model.Issuer.Should().Be(generatedUri.Issuer); + model.Label.Should().Be(generatedUri.Label); + model.Secret.Should().Be(generatedUri.Secret); + model.Digits.Should().Be(generatedUri.Digits); + model.Algorithm.Should().Be(generatedUri.Algorithm); + model.Period.Should().Be(generatedUri.Period); + } + + // Testcases kindly borrowed from: https://github.com/Aftnet/OTPManager/blob/7ba3c4a34bce6ddc83c040de84e36faed4cde60e/OTPManager.Shared.Test/Components/OTPUriConverterTest.cs + [DataTestMethod] + [DataRow("otpauth://totp/Test:Alice Loller%40test.com?secret=ABABABABABABABAB&algorithm=SHA256&digits=6&issuer=Test", "Alice Loller@test.com", "Test")] + [DataRow("otpauth://totp/Test:Alice Loller%40test.com?secret=ABABABABABABABAB&algorithm=SHA256&digits=6", "Alice Loller@test.com", "Test")] + [DataRow("otpauth://totp/Alice Loller%40test.com?secret=ABABABABABABABAB&algorithm=SHA256&digits=6&issuer=Test", "Alice Loller@test.com", "Test")] + [DataRow("otpauth://totp/Omg:Alice Loller%40test.com?secret=ABABABABABABABAB&algorithm=SHA256&digits=6&issuer=Test", "Alice Loller@test.com", "Test")] + [DataRow("otpauth://totp/Alice Loller%40test.com?secret=ABABABABABABABAB&algorithm=SHA256&digits=6", "Alice Loller@test.com", "")] + [DataRow("otpauth://totp/Alice Loller%40test.com?secret=ABABABABABABABAB&algorithm=SHA256&digits=6&issuer=", "Alice Loller@test.com", "")] + [DataRow("otpauth://totp/:Alice Loller%40test.com?secret=ABABABABABABABAB&algorithm=SHA256&digits=6", "Alice Loller@test.com", "")] + public void LabelAndIssuerAreCorrectlyParsed(string uriString, string expectedLabel, string expectedIssuer) + { + var model = new KeyUri(new Uri(uriString)); + + model.Label.Should().Be(expectedLabel); + model.Issuer.Should().Be(expectedIssuer); + } + + // Testcases kindly borrowed from: https://github.com/Aftnet/OTPManager/blob/7ba3c4a34bce6ddc83c040de84e36faed4cde60e/OTPManager.Shared.Test/Components/OTPUriConverterTest.cs + [DataTestMethod] + [DataRow("tpauth://totp/SomeLabel?secret=ABABABABABABABAB&algorithm=SHA256&digits=6", "Uri scheme must be otpauth.")] + [DataRow("otpauth://hotp/SomeLabel?secret=ABABABABABABABAB&algorithm=SHA256&digits=6", "Only totp is supported.")] + [DataRow("otpauth://totp/?secret=ABABABABABABABAB&algorithm=SHA256&digits=6", "No label")] + [DataRow("otpauth://totp/SomeLabel?secret=&algorithm=SHA256&digits=6", "No secret provided.")] + [DataRow("otpauth://totp/SomeLabel?algorithm=SHA256&digits=6", "No secret provided.")] + [DataRow("otpauth://totp/SomeLabel?secret=ABABABA%3D%3D%3DABABAB&algorithm=SHA256&digits=6", "Secret is not valid base32.")] + [DataRow("otpauth://totp/SomeLabel?secret=ABABABAB@ABABABAB&algorithm=SHA256&digits=6", "Secret is not valid base32.")] + [DataRow("otpauth://totp/SomeLabel?secret=ABABABABABABABAB&algorithm=&digits=6", "Not a valid algorithm")] + [DataRow("otpauth://totp/SomeLabel?secret=ABABABABABABABAB&algorithm=SHA12&digits=6", "Not a valid algorithm")] + [DataRow("otpauth://totp/SomeLabel?secret=ABABABABABABABAB&algorithm=SHA256&digits=", "Digits not a number")] + [DataRow("otpauth://totp/SomeLabel?secret=ABABABABABABABAB&algorithm=SHA256&digits=d", "Digits not a number")] + [DataRow("otpauth://totp/SomeLabel?secret=ABABABABABABABAB&algorithm=SHA256&period=", "Period not a number")] + [DataRow("otpauth://totp/SomeLabel?secret=ABABABABABABABAB&algorithm=SHA256&period=d", "Period not a number")] + public void InvalidUriThrowsArgumentOutOfRangeException(string uriString, string msg) + { + Action act = () => new KeyUri(new Uri(uriString)); + + act.Should().Throw().WithMessage(msg + "*"); + } + } +} diff --git a/KeeTrayTOTP/KeeTrayTOTP.csproj b/KeeTrayTOTP/KeeTrayTOTP.csproj index 30097e7..3392052 100644 --- a/KeeTrayTOTP/KeeTrayTOTP.csproj +++ b/KeeTrayTOTP/KeeTrayTOTP.csproj @@ -82,6 +82,7 @@ + Form diff --git a/KeeTrayTOTP/KeyUri.cs b/KeeTrayTOTP/KeyUri.cs new file mode 100644 index 0000000..fbf636a --- /dev/null +++ b/KeeTrayTOTP/KeyUri.cs @@ -0,0 +1,181 @@ +using KeeTrayTOTP.Libraries; +using System; +using System.Collections.Specialized; +using System.Linq; + +namespace KeeTrayTOTP +{ + public class KeyUri + { + private static string[] ValidAlgorithms = new[] { "SHA1", "SHA256", "SHA512" }; + private const string DefaultAlgorithm = "SHA1"; + private const string ValidScheme = "otpauth"; + private const int DefaultDigits = 6; + private const int DefaultPeriod = 30; + + public KeyUri(Uri uri) + { + if (uri == null) + { + throw new ArgumentNullException("uri", "Uri should not be null."); + } + if (uri.Scheme != ValidScheme) + { + throw new ArgumentOutOfRangeException("uri", "Uri scheme must be " + ValidScheme + "."); + } + this.Type = EnsureValidType(uri); + + var parsedQuery = ParseQueryString(uri.Query); + + this.Secret = EnsureValidSecret(parsedQuery); + this.Algorithm = EnsureValidAlgorithm(parsedQuery); + this.Digits = EnsureValidDigits(parsedQuery); + this.Period = EnsureValidPeriod(parsedQuery); + + EnsureValidLabelAndIssuer(uri, parsedQuery); + } + + private void EnsureValidLabelAndIssuer(Uri uri, NameValueCollection query) + { + var label = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/')); + if (string.IsNullOrEmpty(label)) + { + throw new ArgumentOutOfRangeException("uri", "No label"); + } + + var labelParts = label.Split(new[] { ':' }, 2); + if (labelParts.Length == 1) + { + this.Issuer = ""; + this.Label = labelParts[0]; + } + else + { + Issuer = labelParts[0]; + Label = labelParts[1]; + } + + Issuer = query["issuer"] ?? Issuer; + + if (string.IsNullOrWhiteSpace(Label)) + { + throw new ArgumentOutOfRangeException("uri", "No label"); + } + } + + private static string EnsureValidType(Uri uri) + { + if (uri.Host != "totp") + { + throw new ArgumentOutOfRangeException("uri", "Only totp is supported."); + } + return uri.Host; + } + + private int EnsureValidDigits(NameValueCollection query) + { + int digits = DefaultDigits; + if (query.AllKeys.Contains("digits") && !int.TryParse(query["digits"], out digits)) + { + throw new ArgumentOutOfRangeException("query", "Digits not a number"); + } + + return digits; + } + + private int EnsureValidPeriod(NameValueCollection query) + { + int period = DefaultPeriod; + if (query.AllKeys.Contains("period") && !int.TryParse(query["period"], out period)) + { + throw new ArgumentOutOfRangeException("query", "Period not a number"); + } + + return period; + } + + private static string EnsureValidAlgorithm(NameValueCollection query) + { + if (query.AllKeys.Contains("algorithm") && !ValidAlgorithms.Contains(query["algorithm"])) + { + throw new ArgumentOutOfRangeException("query", "Not a valid algorithm"); + } + + return query["algorithm"] ?? DefaultAlgorithm; + } + + private static string EnsureValidSecret(NameValueCollection query) + { + if (string.IsNullOrWhiteSpace(query["secret"])) + { + throw new ArgumentOutOfRangeException("query", "No secret provided."); + } + else if (Base32.HasInvalidPadding(query["secret"])) + { + throw new ArgumentOutOfRangeException("query", "Secret is not valid base32."); + } + else if (!Base32.IsBase32(query["secret"])) + { + throw new ArgumentOutOfRangeException("query", "Secret is not valid base32."); + } + + return query["secret"].TrimEnd('='); + } + + public string Type { get; set; } + public string Secret { get; set; } + public string Algorithm { get; set; } + public int Digits { get; set; } + public int Period { get; set; } + public string Label { get; set; } + public string Issuer { get; set; } + + /// + /// Naive (and probably buggy) query string parser, but we do not want a dependency on System.Web + /// + private static NameValueCollection ParseQueryString(string queryString) + { + var result = new NameValueCollection(); + // remove anything other than query string from url + queryString = queryString.Substring(queryString.IndexOf('?') + 1); + + foreach (var keyValue in queryString.Split('&')) + { + var singlePair = keyValue.Split('='); + if (singlePair.Length == 2) + { + result.Add(singlePair[0], Uri.UnescapeDataString(singlePair[1])); + } + } + + return result; + } + + public Uri GetUri() + { + var newQuery = new NameValueCollection(); + if (Period != 30) + { + newQuery["period"] = Convert.ToString(Period); + } + if (Digits != 6) + { + newQuery["digits"] = Convert.ToString(Digits); + } + if (Algorithm != "SHA1") + { + newQuery["algorithm"] = Algorithm; + } + newQuery["secret"] = Secret; + newQuery["issuer"] = Issuer; + + var builder = new UriBuilder(ValidScheme, Type) + { + Path = "/" + Uri.EscapeDataString(Issuer) + ":" + Uri.EscapeDataString(Label), + Query = string.Join("&", newQuery.AllKeys.Select(key => key + "=" + newQuery[key])) + }; + + return builder.Uri; + } + } +}