Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add KeyUri class for later use as model in storage and qr generation (#…
…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
1 parent
fd9cddf
commit e8f1de5
Showing
3 changed files
with
360 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 + "*"); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} | ||
} | ||
} |