diff --git a/Google.Authenticator.Tests/AuthCodeTest.cs b/Google.Authenticator.Tests/AuthCodeTest.cs index c0ec4cb..2169791 100644 --- a/Google.Authenticator.Tests/AuthCodeTest.cs +++ b/Google.Authenticator.Tests/AuthCodeTest.cs @@ -14,11 +14,9 @@ public void BasicAuthCodeTest() var tfa = new TwoFactorAuthenticator(); - var currentTime = 1416643820; + var currentCounter = 1416643820; - // I actually think you are supposed to divide the time by 30 seconds? - // Maybe need an overload that takes a DateTime? - var actual = tfa.GeneratePINAtInterval(secretKey, currentTime, 6); + var actual = tfa.GeneratePINAtInterval(secretKey, currentCounter, 6); actual.ShouldBe(expected); } diff --git a/Google.Authenticator.Tests/IntervalTests.cs b/Google.Authenticator.Tests/IntervalTests.cs new file mode 100644 index 0000000..ff82d55 --- /dev/null +++ b/Google.Authenticator.Tests/IntervalTests.cs @@ -0,0 +1,38 @@ +using Xunit; +using Shouldly; +using System.Collections.Generic; +using System.Text; +using System; + +namespace Google.Authenticator.Tests +{ + public class IntervalTests + { + const string secret = "ggggjhG&^*&^jfSSSddd"; + + [Theory] + [MemberData(nameof(GetTestValues))] + public void GetCurrentPinsHandlesDifferentIntervals(int timeTolerance, int timeStep, int expectedCount) + { + var subject = new TwoFactorAuthenticator(timeStep); + + subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(timeTolerance)).Length.ShouldBe(expectedCount); + } + + public static IEnumerable GetTestValues() + { + yield return new object[] { 15, 30, 1 }; + yield return new object[] { 30, 30, 3 }; + yield return new object[] { 60, 30, 5 }; + + yield return new object[] { 15, 15, 3 }; + yield return new object[] { 30, 15, 5 }; + yield return new object[] { 60, 15, 9 }; + + yield return new object[] { 15, 60, 1 }; + yield return new object[] { 30, 60, 1 }; + yield return new object[] { 60, 60, 3 }; + yield return new object[] { 300, 60, 11 }; + } + } +} \ No newline at end of file diff --git a/Google.Authenticator.Tests/ValidationTests.cs b/Google.Authenticator.Tests/SecretTypeTests.cs similarity index 71% rename from Google.Authenticator.Tests/ValidationTests.cs rename to Google.Authenticator.Tests/SecretTypeTests.cs index 62f8275..1022c7b 100644 --- a/Google.Authenticator.Tests/ValidationTests.cs +++ b/Google.Authenticator.Tests/SecretTypeTests.cs @@ -6,7 +6,7 @@ namespace Google.Authenticator.Tests { - public class ValidationTests + public class SecretTypeTests { const string secret = "ggggjhG&^*&^jfSSSddd"; private readonly static byte[] secretAsBytes = Encoding.UTF8.GetBytes(secret); @@ -72,81 +72,6 @@ public void ValidateWorksWithDifferentSecretTypesSHA512(string pin, int irreleva subject.ValidateTwoFactorPIN(secretAsBytes, pin, irrelevantNumberToAvoidDuplicatePinsBeingRemoved * 2); } - [Fact] - public void GetCurrentPinsHandles15SecondInterval() - { - // This is nonsensical, really, as anything less than 30 == 0 in practice. - var subject = new TwoFactorAuthenticator(); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(15)).Length.ShouldBe(1); - } - - [Fact] - public void GetCurrentPinsHandles15SecondIntervalSHA256() - { - // This is nonsensical, really, as anything less than 30 == 0 in practice. - var subject = new TwoFactorAuthenticator(HashType.SHA256); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(15)).Length.ShouldBe(1); - } - - [Fact] - public void GetCurrentPinsHandles15SecondIntervalSHA512() - { - // This is nonsensical, really, as anything less than 30 == 0 in practice. - var subject = new TwoFactorAuthenticator(HashType.SHA512); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(15)).Length.ShouldBe(1); - } - - [Fact] - public void GetCurrentPinsHandles30SecondInterval() - { - var subject = new TwoFactorAuthenticator(); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(30)).Length.ShouldBe(3); - } - - [Fact] - public void GetCurrentPinsHandles30SecondIntervalSHA256() - { - var subject = new TwoFactorAuthenticator(HashType.SHA256); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(30)).Length.ShouldBe(3); - } - - [Fact] - public void GetCurrentPinsHandles30SecondIntervalSHA512() - { - var subject = new TwoFactorAuthenticator(HashType.SHA512); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(30)).Length.ShouldBe(3); - } - - [Fact] - public void GetCurrentPinsHandles60SecondInterval() - { - var subject = new TwoFactorAuthenticator(); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(60)).Length.ShouldBe(5); - } - - [Fact] - public void GetCurrentPinsHandles60SecondIntervalSHA256() - { - var subject = new TwoFactorAuthenticator(HashType.SHA256); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(60)).Length.ShouldBe(5); - } - - [Fact] - public void GetCurrentPinsHandles60SecondIntervalSHA512() - { - var subject = new TwoFactorAuthenticator(HashType.SHA512); - - subject.GetCurrentPINs(secret, TimeSpan.FromSeconds(60)).Length.ShouldBe(5); - } - public static IEnumerable GetPins() { var subject = new TwoFactorAuthenticator(); diff --git a/Google.Authenticator.Tests/TimeStepTests.cs b/Google.Authenticator.Tests/TimeStepTests.cs new file mode 100644 index 0000000..43fa4ff --- /dev/null +++ b/Google.Authenticator.Tests/TimeStepTests.cs @@ -0,0 +1,45 @@ +using Xunit; +using Shouldly; +using System; + +namespace Google.Authenticator.Tests +{ + public class TimeStepTests + { + [Fact] + public void DefaultPINHasNotBeenChangedByAddingTimeStepConfig() + { + var now = new DateTime(2024,1,2,3,4,5,DateTimeKind.Utc); + var secret = "12314241234342342"; + var defaultPin = new TwoFactorAuthenticator().GetCurrentPIN(secret, now); + + defaultPin.ShouldBe("668182"); // This pin was created with the code from before the timestep config was added + } + + [Fact] + public void DifferentTimeStepsReturnsDifferentPINs() + { + var now = new DateTime(2024,1,2,3,4,5,DateTimeKind.Utc); + var secret = "12314241234342342"; + var defaultPin = new TwoFactorAuthenticator().GetCurrentPIN(secret, now); + var pinWith15SecondTimeStep = new TwoFactorAuthenticator(15).GetCurrentPIN(secret, now); + var pinWith60SecondTimeStep = new TwoFactorAuthenticator(60).GetCurrentPIN(secret, now); + + defaultPin.ShouldNotBe(pinWith15SecondTimeStep); + defaultPin.ShouldNotBe(pinWith60SecondTimeStep); + pinWith15SecondTimeStep.ShouldNotBe(pinWith60SecondTimeStep); + } + + [Fact] + public void DefaultTimeStepGivesSamePinAs30() + { + var now = new DateTime(2024,1,2,3,4,5,DateTimeKind.Utc); + var secret = "12314241234342342"; + var defaultPin = new TwoFactorAuthenticator().GetCurrentPIN(secret, now); + var pinWith30SecondTimeStep = new TwoFactorAuthenticator(30).GetCurrentPIN(secret, now); + + defaultPin.ShouldBe(pinWith30SecondTimeStep); + + } + } +} \ No newline at end of file diff --git a/Google.Authenticator/Google.Authenticator.csproj b/Google.Authenticator/Google.Authenticator.csproj index e22fe34..5f4487e 100644 --- a/Google.Authenticator/Google.Authenticator.csproj +++ b/Google.Authenticator/Google.Authenticator.csproj @@ -6,8 +6,8 @@ Google Authenticator Two-Factor Authentication Library (Not officially affiliated with Google.) Brandon Potter Brandon Potter - 3.2.0 - Added support for HMACSHA256 and HMACSHA512 as per the RFC spec. Care should be taken by the developer to ensure compatible apps are used. + 3.3.0-beta1 + Added the ability to use a different Time Step than the default 30 seconds Apache-2.0 https://github.com/BrandonPotter/GoogleAuthenticator GoogleAuthenticator diff --git a/Google.Authenticator/TwoFactorAuthenticator.cs b/Google.Authenticator/TwoFactorAuthenticator.cs index 9896f88..50dbb24 100644 --- a/Google.Authenticator/TwoFactorAuthenticator.cs +++ b/Google.Authenticator/TwoFactorAuthenticator.cs @@ -19,18 +19,32 @@ public class TwoFactorAuthenticator private static readonly DateTime _epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - private TimeSpan DefaultClockDriftTolerance { get; set; } + private readonly TimeSpan DefaultClockDriftTolerance; - private HashType HashType { get; set; } + private readonly HashType HashType; - public TwoFactorAuthenticator() : this(HashType.SHA1) + private readonly int timeStep; + + public TwoFactorAuthenticator() : this(HashType.SHA1) {} - public TwoFactorAuthenticator(HashType hashType) + public TwoFactorAuthenticator(HashType hashType) : this(hashType, 30) + { + } + + public TwoFactorAuthenticator(int timeStep) : this(HashType.SHA1, timeStep) + {} + /// + /// Initializes a new instance of the class. + /// + /// The type of Hash to generate (default is SHA1) + /// The length of the "time step" - i.e. how often the code changes. Default is 30. + public TwoFactorAuthenticator(HashType hashType, int timeStep) { HashType = hashType; DefaultClockDriftTolerance = TimeSpan.FromMinutes(5); + this.timeStep = timeStep; } /// @@ -177,11 +191,13 @@ private string GenerateHashedCode(byte[] key, long iterationNumber, int digits = return password.ToString(new string('0', digits)); } - private long GetCurrentCounter() => GetCurrentCounter(DateTime.UtcNow, _epoch, 30); + private long GetCurrentCounter() => GetCurrentCounter(DateTime.UtcNow, _epoch); - private long GetCurrentCounter(DateTime now, DateTime epoch, int timeStep) => + private long GetCurrentCounter(DateTime now, DateTime epoch) => (long) (now - epoch).TotalSeconds / timeStep; + + /// /// Given a PIN from a client, check if it is valid at the current time. /// @@ -262,7 +278,7 @@ public string GetCurrentPIN(string accountSecretKey, bool secretIsBase32 = false /// Flag saying if accountSecretKey is in Base32 format or original secret /// A 6-digit PIN public string GetCurrentPIN(string accountSecretKey, DateTime now, bool secretIsBase32 = false) => - GeneratePINAtInterval(accountSecretKey, GetCurrentCounter(now, _epoch, 30), secretIsBase32: secretIsBase32); + GeneratePINAtInterval(accountSecretKey, GetCurrentCounter(now, _epoch), secretIsBase32: secretIsBase32); /// /// Get the PIN for current time; the same code that a 2FA app would generate for the current time. @@ -281,7 +297,7 @@ public string GetCurrentPIN(byte[] accountSecretKey) => /// The time you wish to generate the pin for /// A 6-digit PIN public string GetCurrentPIN(byte[] accountSecretKey, DateTime now) => - GeneratePINAtInterval(accountSecretKey, GetCurrentCounter(now, _epoch, 30)); + GeneratePINAtInterval(accountSecretKey, GetCurrentCounter(now, _epoch)); /// /// Get all the PINs that would be valid within the time window allowed for by the default clock drift. @@ -320,8 +336,8 @@ public string[] GetCurrentPINs(byte[] accountSecretKey, TimeSpan timeTolerance) { var iterationOffset = 0; - if (timeTolerance.TotalSeconds >= 30) - iterationOffset = Convert.ToInt32(timeTolerance.TotalSeconds / 30.00); + if (timeTolerance.TotalSeconds >= timeStep) + iterationOffset = Convert.ToInt32(timeTolerance.TotalSeconds / timeStep); return GetCurrentPINs(accountSecretKey, iterationOffset); } diff --git a/README.md b/README.md index d8f6524..e01d410 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,11 @@ bool result = tfa.ValidateTwoFactorPIN(key, txtCode.Text) ## Update history +### 3.3.0 + +Added support for configuring the "time step". This is basically how often the code changes. +The default used by most authenticator apps is 30 seconds, but some hardware devices use 60 seconds. You can now specify this in the constructor. + ### 3.2.0 Added support for HMACSHA256 and HMACSHA512 as per the [RFC spec](https://datatracker.ietf.org/doc/html/rfc6238#section-1.2). In testing it was found that several popular apps (such as Authy and Microsoft Authenticator) may not have support for these algorithms so care should be taken by the developer to ensure compatible apps are used.