From 92b9d319552987a3d79ce740d0a804463b55eedb Mon Sep 17 00:00:00 2001 From: Fabian Neundorf Date: Sat, 23 Nov 2024 16:05:57 +0100 Subject: [PATCH] Add support for parsing an Uri to OtpUri --- src/Otp.NET/OtpUri.cs | 135 +++++++++++++++++++++++++++++--- test/Otp.NET.Test/OtpUriTest.cs | 58 ++++++++++++++ 2 files changed, 183 insertions(+), 10 deletions(-) diff --git a/src/Otp.NET/OtpUri.cs b/src/Otp.NET/OtpUri.cs index ca801e1..7c64cb7 100644 --- a/src/Otp.NET/OtpUri.cs +++ b/src/Otp.NET/OtpUri.cs @@ -1,12 +1,23 @@ -using System; +using System; using System.Collections.Generic; using System.Text; +using System.Text.RegularExpressions; namespace OtpNet; // See https://github.com/google/google-authenticator/wiki/Key-Uri-Format public class OtpUri { + private const OtpHashMode DEFAULT_HASH_MODE = OtpHashMode.Sha1; + private const int DEFAULT_DIGITS = 6; + private const int DEFAULT_PERIOD = 30; + private const int DEFAULT_COUNTER = 0; + private const string SCHEME = "otpauth"; + private static readonly Regex queryParameterRegex = new(@"[?&](\w[\w.]*)=([^?&]+)"); + private static readonly Regex accountAndIssuerRegex = new("^/(?:([^:]+):)? *([^:]+)$"); + + private delegate bool ParseNumber(string s, System.Globalization.NumberStyles style, IFormatProvider provider, out T result); + /// /// Create a new OTP Auth Uri /// @@ -15,10 +26,10 @@ public OtpUri( string secret, string user, string issuer = null, - OtpHashMode algorithm = OtpHashMode.Sha1, - int digits = 6, - int period = 30, - long counter = 0) + OtpHashMode algorithm = DEFAULT_HASH_MODE, + int digits = DEFAULT_DIGITS, + int period = DEFAULT_PERIOD, + long counter = DEFAULT_COUNTER) { _ = secret ?? throw new ArgumentNullException(nameof(secret)); _ = user ?? throw new ArgumentNullException(nameof(user)); @@ -53,14 +64,117 @@ public OtpUri( byte[] secret, string user, string issuer = null, - OtpHashMode algorithm = OtpHashMode.Sha1, - int digits = 6, - int period = 30, - long counter = 0) + OtpHashMode algorithm = DEFAULT_HASH_MODE, + int digits = DEFAULT_DIGITS, + int period = DEFAULT_PERIOD, + long counter = DEFAULT_COUNTER) : this(schema, Base32Encoding.ToString(secret), user, issuer, algorithm, digits, period, counter) { } + public OtpUri(string uri) + : this(new Uri(uri)) + { } + + public OtpUri(Uri uri) + { + _ = uri ?? throw new ArgumentNullException(nameof(uri)); + + if (uri.Scheme != SCHEME) + { + throw new ArgumentException($"Uri must use scheme {SCHEME}", nameof(uri)); + } + + T? DetermineEnum(string str) where T : struct, Enum + { + foreach (T type in Enum.GetValues(typeof(T))) + { + string typeString = type.ToString(); + if (typeString.Equals(str, StringComparison.InvariantCultureIgnoreCase)) + { + return type; + } + } + return null; + } + + void Parse(string key, string value, ref T? result, ParseNumber parse) where T : struct + { + if (result.HasValue) throw new ArgumentException($"Uri supplies '{key}' parameter multiple times", nameof(uri)); + if (!parse(value, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var parsedResult)) + { + throw new ArgumentException($"Uri '{key}' parameter '{value}' is not a valid integer", nameof(uri)); + } + result = parsedResult; + } + + OtpType? determinedType = DetermineEnum(uri.Authority); + if (!determinedType.HasValue) throw new ArgumentException("Uri uses no known type", nameof(uri)); + Type = determinedType.Value; + + // Contains the leading path delimiter + var accountAndIssuerMatch = accountAndIssuerRegex.Match(uri.LocalPath); + if (accountAndIssuerMatch.Success) + { + Group issuerGroup = accountAndIssuerMatch.Groups[1]; + Issuer = issuerGroup.Success ? issuerGroup.Value : null; + User = accountAndIssuerMatch.Groups[2].Value; + } + + // Parse query parameters + OtpHashMode? algorithm = null; + int? digits = null; + long? counter = null; + int? period = null; + + var queryParameterMatch = queryParameterRegex.Match(uri.Query); + while (queryParameterMatch.Success) + { + string key = queryParameterMatch.Groups[1].Value.ToLower(); + string value = Uri.UnescapeDataString(queryParameterMatch.Groups[2].Value); + + switch (key) + { + case "secret": + Secret = value; + break; + case "issuer": + if (Issuer != null && Issuer != value) throw new ArgumentException($"Uri supplies different issuers in label ({Issuer}) and parameter ({value})", nameof(uri)); + Issuer = value; + break; + case "algorithm": + if (algorithm.HasValue) throw new ArgumentException("Uri supplies 'algorithm' parameter multiple times", nameof(uri)); + algorithm = DetermineEnum(value); + if (!algorithm.HasValue) throw new ArgumentException($"Uri 'algorithm' parameter '{value}' uses no known algorithm", nameof(uri)); + break; + case "digits": + Parse(key, value, ref digits, int.TryParse); + break; + case "counter": + if (Type != OtpType.Hotp) throw new ArgumentException($"Uri 'counter' parameter is not valid for type '{Type}'", nameof(uri)); + Parse(key, value, ref counter, long.TryParse); + break; + case "period": + if (Type != OtpType.Totp) throw new ArgumentException($"Uri 'period' parameter is not valid for type '{Type}'", nameof(uri)); + Parse(key, value, ref period, int.TryParse); + break; + default: + throw new ArgumentException($"Unknown parameter '{key}' in query string of uri", nameof(uri)); + } + + queryParameterMatch = queryParameterMatch.NextMatch(); + } + + if (Secret == null) throw new ArgumentException($"Uri didn't provide the mandatory parameter 'secret'"); + // throws when Secret does contain invalid characters + _ = Base32Encoding.ToBytes(Secret); + + Algorithm = algorithm ?? DEFAULT_HASH_MODE; + Digits = digits ?? DEFAULT_DIGITS; + Period = period ?? DEFAULT_PERIOD; + Counter = counter ?? DEFAULT_COUNTER; + } + /// /// What type of OTP is this uri for /// @@ -141,7 +255,8 @@ public override string ToString() break; } - var uriBuilder = new StringBuilder("otpauth://"); + var uriBuilder = new StringBuilder(SCHEME); + uriBuilder.Append("://"); uriBuilder.Append(Type.ToString().ToLowerInvariant()); uriBuilder.Append("/"); diff --git a/test/Otp.NET.Test/OtpUriTest.cs b/test/Otp.NET.Test/OtpUriTest.cs index 4e4a574..eb103a1 100644 --- a/test/Otp.NET.Test/OtpUriTest.cs +++ b/test/Otp.NET.Test/OtpUriTest.cs @@ -22,5 +22,63 @@ public void GenerateOtpUriTest(string secret, OtpType otpType, string user, stri { var uriString = new OtpUri(otpType, secret, user, issuer, hash, digits, period, counter).ToString(); Assert.That(uriString, Is.EqualTo(expectedUri)); + + var parsedOtpUri = new OtpUri(expectedUri); + Assert.That(parsedOtpUri.Secret, Is.EqualTo(secret)); + Assert.That(parsedOtpUri.Type, Is.EqualTo(otpType)); + Assert.That(parsedOtpUri.User, Is.EqualTo(user)); + Assert.That(parsedOtpUri.Issuer, Is.EqualTo(issuer)); + Assert.That(parsedOtpUri.Algorithm, Is.EqualTo(hash)); + Assert.That(parsedOtpUri.Digits, Is.EqualTo(digits)); + Assert.That(parsedOtpUri.Period, Is.EqualTo(period)); + Assert.That(parsedOtpUri.Counter, Is.EqualTo(counter)); + } + + [TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0, + "otpauth://totp/ACME%20Co:%20alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] + [TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0, + "otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP")] + [TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0, + "otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30")] + [TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0, + "otpauth://totp/alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] + [TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0, + "otpauth://totp/ACME%20Co%3Aalice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] + public void ParseOtpUriTest(string expectedSecret, OtpType expectedOtpType, string expectedUser, string expectedIssuer, + OtpHashMode expectedHash, int expectedDigits, int expectedPeriod, int expectedCounter, string uri) + { + var parsedOtpUri = new OtpUri(uri); + Assert.That(parsedOtpUri.Secret, Is.EqualTo(expectedSecret)); + Assert.That(parsedOtpUri.Type, Is.EqualTo(expectedOtpType)); + Assert.That(parsedOtpUri.User, Is.EqualTo(expectedUser)); + Assert.That(parsedOtpUri.Issuer, Is.EqualTo(expectedIssuer)); + Assert.That(parsedOtpUri.Algorithm, Is.EqualTo(expectedHash)); + Assert.That(parsedOtpUri.Digits, Is.EqualTo(expectedDigits)); + Assert.That(parsedOtpUri.Period, Is.EqualTo(expectedPeriod)); + Assert.That(parsedOtpUri.Counter, Is.EqualTo(expectedCounter)); + } + + [TestCase("http://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // invalid scheme + [TestCase("otpauth://invalid/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // invalid type + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=Different&algorithm=SHA1&digits=6&period=30")] // different issuers + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // missing secret + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=1IsInvalid&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // invalid secret + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=Invalid&digits=6&period=30")] // invalid algorithm + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=invalid&period=30")] // invalid digits + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=-1&period=30")] // negative digits + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=invalid")] // invalid period + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=-1")] // negative period + [TestCase("otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30&counter=0")] // counter with totp + [TestCase("otpauth://hotp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] // period with htop + [TestCase("otpauth://hotp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&counter=invalid")] // invalid counter + [TestCase("otpauth://hotp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&counter=-1")] // negative counter + public void ParseInvalidOtpUriTest(string uri) + { + void Constructor() + { + var _ = new OtpUri(uri); + } + + Assert.Throws(Constructor); } }