Skip to content

Commit

Permalink
Add KeyUri class for later use as model in storage and qr generation (#…
Browse files Browse the repository at this point in the history
…139)

* Add KeyUri class for later use as model in storage and qr generation

* Rename a few fields

* Cover remaining code with tests.

* Process PR comments
  • Loading branch information
robinvanpoppel committed May 4, 2020
1 parent fd9cddf commit e8f1de5
Show file tree
Hide file tree
Showing 3 changed files with 360 additions and 0 deletions.
178 changes: 178 additions & 0 deletions 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<ArgumentNullException>().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<ArgumentOutOfRangeException>().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<ArgumentOutOfRangeException>().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<ArgumentOutOfRangeException>().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<ArgumentOutOfRangeException>().WithMessage(msg + "*");
}
}
}
1 change: 1 addition & 0 deletions KeeTrayTOTP/KeeTrayTOTP.csproj
Expand Up @@ -82,6 +82,7 @@
<Compile Include="Menu\MainMenuItemProvider.cs" />
<Compile Include="Menu\MenuItemProvider.cs" />
<Compile Include="Menu\LegacyTrayMenuItemProvider.cs" />
<Compile Include="KeyUri.cs" />
<Compile Include="Libraries\DropDownLocationCalculator.cs" />
<Compile Include="FormAbout.cs">
<SubType>Form</SubType>
Expand Down
181 changes: 181 additions & 0 deletions 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; }

/// <summary>
/// Naive (and probably buggy) query string parser, but we do not want a dependency on System.Web
/// </summary>
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;
}
}
}

0 comments on commit e8f1de5

Please sign in to comment.