diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..8fc44d2 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,30 @@ +name: .NET Build + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore + - name: Test + run: dotnet test --no-build --verbosity normal + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: otpnet-nuget-package + path: src/Otp.NET/bin/Debug/*.nupkg diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml new file mode 100644 index 0000000..3d64248 --- /dev/null +++ b/.github/workflows/nuget.yml @@ -0,0 +1,39 @@ +name: Upload NuGet package + +on: + release: + types: [created] + +jobs: + deploy: + + runs-on: ubuntu-latest + + permissions: + packages: write + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore signing key + env: + SIGNING_KEY: ${{ secrets.VS_SIGNING_KEY }} + run: | + echo $SIGNING_KEY | base64 --decode > src/Otp.NET/vs-signing-key.snk + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore --configuration Release + - name: Publish to nuget.org + run: dotnet nuget push src/Otp.NET/bin/Release/*.nupkg -k $NUGET_AUTH_TOKEN -s https://api.nuget.org/v3/index.json + env: + NUGET_AUTH_TOKEN: ${{ secrets.NUGET_TOKEN }} + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: otpnet-nuget-package + path: src/Otp.NET/bin/Release/*.nupkg \ No newline at end of file diff --git a/.gitignore b/.gitignore index 726cc91..b0d39c8 100644 --- a/.gitignore +++ b/.gitignore @@ -252,4 +252,5 @@ paket-files/ *.sln.iml .nuget/ -node_modules/ \ No newline at end of file +node_modules/ +*.snk \ No newline at end of file diff --git a/Otp.NET.sln b/Otp.NET.sln index a607f5e..e0e2363 100644 --- a/Otp.NET.sln +++ b/Otp.NET.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.29411.108 +# Visual Studio Version 17 +VisualStudioVersion = 17.4.33110.190 MinimumVisualStudioVersion = 15.0.26124.0 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D0E9D08A-7F66-426A-8645-C2D92DB4D200}" EndProject @@ -12,7 +12,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{81C89CBC-2 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{2A4A70D0-4968-4E0C-9E42-4AF2C076D38B}" ProjectSection(SolutionItems) = preProject - appveyor.yml = appveyor.yml + .gitignore = .gitignore + .github\workflows\dotnet.yml = .github\workflows\dotnet.yml + LICENSE.txt = LICENSE.txt + .github\workflows\nuget.yml = .github\workflows\nuget.yml README.md = README.md EndProjectSection EndProject diff --git a/README.md b/README.md index 5888976..3e0af8a 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,27 @@ An implementation TOTP [RFC 6238](http://tools.ietf.org/html/rfc6238) and HOTP [RFC 4226](http://tools.ietf.org/html/rfc4226) in C#. +[![.NET build status (master)](https://github.com/kspearrin/Otp.NET/actions/workflows/dotnet.yml/badge.svg?branch=master)](https://github.com/kspearrin/Otp.NET/actions/workflows/dotnet.yml) + ## Get it on NuGet https://www.nuget.org/packages/Otp.NET +```powershell +Install-Package Otp.NET ``` -PM> Install-Package Otp.NET + +or + +```bash +dotnet add package Otp.NET ``` ## Documentation - [TOTP (Timed One Time Password)](#totp-timed-one-time-password) - [HOTP (HMAC-based One Time Password)](#hotp-hmac-based-one-time-password) +- [OTP Uri](#otp-uri) - [Base32 Encoding](#base32-encoding) ### TOTP (Timed One Time Password) @@ -184,7 +193,7 @@ var hotp = new Hotp(secretKey, mode: OtpHashMode.Sha512); Finally the truncation level can be specified. Basically this is how many digits do you want your HOTP code to be. The tests in the RFC specify 8, but 6 has become a de-facto standard if not an actual one. For this reason the default is 6 but you can set it to something else. There aren't a lot of tests around this either so use at your own risk (other than the fact that the RFC test table uses HOTP values that are 8 digits). ```c# -var hotp = new Hotp(secretKey, totpSize: 8); +var hotp = new Hotp(secretKey, hotpSize: 8); ``` #### Verification @@ -192,7 +201,16 @@ var hotp = new Hotp(secretKey, totpSize: 8); The HOTP implementation provides a mechanism for verifying HOTP codes that are passed in. There is a method called VerifyHotp with an overload that takes a counter value. ```c# -public bool VerifyHotp(string totp, long counter); +public bool VerifyHotp(string hotp, long counter); +``` + +### OTP Uri + +You can use the OtpUri class to generate OTP style uris in the "Key Uri Format" as defined here: https://github.com/google/google-authenticator/wiki/Key-Uri-Format + +```c# +var uriString = new OtpUri(OtpType.Totp, "JBSWY3DPEHPK3PXP", "alice@google.com", "ACME Co").ToString(); +// uriString is otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30 ``` ### Base32 Encoding diff --git a/appveyor.yml b/appveyor.yml deleted file mode 100644 index aed33b8..0000000 --- a/appveyor.yml +++ /dev/null @@ -1,14 +0,0 @@ -before_build: - - dotnet restore - -build_script: - - dotnet build --no-restore - - dotnet pack src\Otp.NET\Otp.NET.csproj --no-build -o ../dist - -test_script: - - dotnet test --no-build - -deploy: off - -artifacts: - - path: 'dist\*.nupkg' diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..65708d8 Binary files /dev/null and b/icon.png differ diff --git a/src/Otp.NET/Base32Encoding.cs b/src/Otp.NET/Base32Encoding.cs index 4a64c5b..982058a 100644 --- a/src/Otp.NET/Base32Encoding.cs +++ b/src/Otp.NET/Base32Encoding.cs @@ -6,128 +6,136 @@ using System; -namespace OtpNet +namespace OtpNet; + +public class Base32Encoding { - public class Base32Encoding + public static byte[] ToBytes(string input) { - public static byte[] ToBytes(string input) + if (string.IsNullOrEmpty(input)) { - if(string.IsNullOrEmpty(input)) - { - throw new ArgumentNullException("input"); - } + throw new ArgumentNullException(nameof(input)); + } + + // remove padding characters + input = input.TrimEnd('='); + // this must be TRUNCATED + var byteCount = input.Length * 5 / 8; + var returnArray = new byte[byteCount]; - input = input.TrimEnd('='); //remove padding characters - int byteCount = input.Length * 5 / 8; //this must be TRUNCATED - byte[] returnArray = new byte[byteCount]; + byte curByte = 0, bitsRemaining = 8; + var arrayIndex = 0; - byte curByte = 0, bitsRemaining = 8; - int mask = 0, arrayIndex = 0; + foreach (var c in input) + { + var cValue = CharToValue(c); - foreach(char c in input) + int mask; + if (bitsRemaining > 5) { - int cValue = CharToValue(c); - - if(bitsRemaining > 5) - { - mask = cValue << (bitsRemaining - 5); - curByte = (byte)(curByte | mask); - bitsRemaining -= 5; - } - else - { - mask = cValue >> (5 - bitsRemaining); - curByte = (byte)(curByte | mask); - returnArray[arrayIndex++] = curByte; - curByte = (byte)(cValue << (3 + bitsRemaining)); - bitsRemaining += 3; - } + mask = cValue << (bitsRemaining - 5); + curByte = (byte)(curByte | mask); + bitsRemaining -= 5; } - - //if we didn't end with a full byte - if(arrayIndex != byteCount) + else { - returnArray[arrayIndex] = curByte; + mask = cValue >> (5 - bitsRemaining); + curByte = (byte)(curByte | mask); + returnArray[arrayIndex++] = curByte; + curByte = (byte)(cValue << (3 + bitsRemaining)); + bitsRemaining += 3; } - - return returnArray; } - public static string ToString(byte[] input) + // if we didn't end with a full byte + if (arrayIndex != byteCount) { - if(input == null || input.Length == 0) - { - throw new ArgumentNullException("input"); - } + returnArray[arrayIndex] = curByte; + } - int charCount = (int)Math.Ceiling(input.Length / 5d) * 8; - char[] returnArray = new char[charCount]; + return returnArray; + } - byte nextChar = 0, bitsRemaining = 5; - int arrayIndex = 0; + public static string ToString(byte[] input) + { + if (input == null || input.Length == 0) + { + throw new ArgumentNullException(nameof(input)); + } - foreach(byte b in input) - { - nextChar = (byte)(nextChar | (b >> (8 - bitsRemaining))); - returnArray[arrayIndex++] = ValueToChar(nextChar); + var charCount = (int)Math.Ceiling(input.Length / 5d) * 8; + var returnArray = new char[charCount]; - if(bitsRemaining < 4) - { - nextChar = (byte)((b >> (3 - bitsRemaining)) & 31); - returnArray[arrayIndex++] = ValueToChar(nextChar); - bitsRemaining += 5; - } + byte nextChar = 0, bitsRemaining = 5; + var arrayIndex = 0; - bitsRemaining -= 3; - nextChar = (byte)((b << bitsRemaining) & 31); - } + foreach (byte b in input) + { + nextChar = (byte)(nextChar | (b >> (8 - bitsRemaining))); + returnArray[arrayIndex++] = ValueToChar(nextChar); - //if we didn't end with a full char - if(arrayIndex != charCount) + if (bitsRemaining < 4) { + nextChar = (byte)((b >> (3 - bitsRemaining)) & 31); returnArray[arrayIndex++] = ValueToChar(nextChar); - while(arrayIndex != charCount) returnArray[arrayIndex++] = '='; //padding + bitsRemaining += 5; } - return new string(returnArray); + bitsRemaining -= 3; + nextChar = (byte)((b << bitsRemaining) & 31); } - private static int CharToValue(char c) + // if we didn't end with a full char + if (arrayIndex != charCount) { - int value = (int)c; - - //65-90 == uppercase letters - if(value < 91 && value > 64) + returnArray[arrayIndex++] = ValueToChar(nextChar); + // padding + while (arrayIndex != charCount) { - return value - 65; - } - //50-55 == numbers 2-7 - if(value < 56 && value > 49) - { - return value - 24; - } - //97-122 == lowercase letters - if(value < 123 && value > 96) - { - return value - 97; + returnArray[arrayIndex++] = '='; } + } + + return new string(returnArray); + } + + private static int CharToValue(char c) + { + int value = c; - throw new ArgumentException("Character is not a Base32 character.", "c"); + // 65-90 == uppercase letters + if (value < 91 && value > 64) + { + return value - 65; } - private static char ValueToChar(byte b) + // 50-55 == numbers 2-7 + if (value < 56 && value > 49) { - if(b < 26) - { - return (char)(b + 65); - } + return value - 24; + } - if(b < 32) - { - return (char)(b + 24); - } + // 97-122 == lowercase letters + if (value < 123 && value > 96) + { + return value - 97; + } - throw new ArgumentException("Byte is not a Base32 value.", "b"); + throw new ArgumentException("Character is not a Base32 character.", nameof(c)); + } + + private static char ValueToChar(byte b) + { + if (b < 26) + { + return (char)(b + 65); } + + if (b < 32) + { + return (char)(b + 24); + } + + throw new ArgumentException("Byte is not a Base32 value.", nameof(b)); } } diff --git a/src/Otp.NET/Hotp.cs b/src/Otp.NET/Hotp.cs index 705b74b..be53750 100644 --- a/src/Otp.NET/Hotp.cs +++ b/src/Otp.NET/Hotp.cs @@ -16,7 +16,7 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER @@ -25,94 +25,86 @@ DEALINGS IN THE SOFTWARE. using System; -namespace OtpNet +namespace OtpNet; + +/// +/// Calculate HMAC-Based One-Time-Passwords (HOTP) from a secret key +/// +/// +/// The specifications for this are found in RFC 4226 +/// http://tools.ietf.org/html/rfc4226 +/// +public class Hotp : Otp { /// - /// Calculate HMAC-Based One-Time-Passwords (HOTP) from a secret key + /// Create a HOTP instance /// - /// - /// The specifications for this are found in RFC 4226 - /// http://tools.ietf.org/html/rfc4226 - /// - public class Hotp : Otp + /// The secret key to use in HOTP calculations + /// The hash mode to use + /// The number of digits that the returning HOTP should have. The default is 6. + public Hotp(byte[] secretKey, OtpHashMode mode = OtpHashMode.Sha1, int hotpSize = 6) + : base(secretKey, mode) { - private readonly int hotpSize; - - /// - /// Create a HOTP instance - /// - /// The secret key to use in HOTP calculations - /// The hash mode to use - /// The number of digits that the returning HOTP should have. The default is 6. - public Hotp(byte[] secretKey, OtpHashMode mode = OtpHashMode.Sha1, int hotpSize = 6) - : base(secretKey, mode) - { - VerifyParameters(hotpSize); + VerifyParameters(hotpSize); + HotpSize = hotpSize; + } - this.hotpSize = hotpSize; - } + /// + /// Create a HOTP instance + /// + /// The key to use in HOTP calculations + /// The hash mode to use + /// The number of digits that the returning HOTP should have. The default is 6. + public Hotp(IKeyProvider key, OtpHashMode mode = OtpHashMode.Sha1, int hotpSize = 6) + : base(key, mode) + { + VerifyParameters(hotpSize); + HotpSize = hotpSize; + } - /// - /// Create a HOTP instance - /// - /// The key to use in HOTP calculations - /// The hash mode to use - /// The number of digits that the returning HOTP should have. The default is 6. - public Hotp(IKeyProvider key, OtpHashMode mode = OtpHashMode.Sha1, int hotpSize = 6) - : base(key, mode) - { - VerifyParameters(hotpSize); + /// + /// Gets the number of digits that the returning HOTP should have. + /// + public int HotpSize { get; private set; } - this.hotpSize = hotpSize; - } + /// + /// Takes a counter and then computes a HOTP value + /// + /// The timestamp to use for the HOTP calculation + /// + /// a HOTP value + public string ComputeHOTP(long counter) => Compute(counter, _hashMode); - private static void VerifyParameters(int hotpSize) - { - if(!(hotpSize >= 6)) - throw new ArgumentOutOfRangeException("hotpSize"); - if(!(hotpSize <= 8)) - throw new ArgumentOutOfRangeException("hotpSize"); - } + /// + /// Verify a value that has been provided with the calculated value + /// + /// the trial HOTP value + /// The counter value to verify + /// True if there is a match. + public bool VerifyHotp(string hotp, long counter) => hotp == ComputeHOTP(counter); - /// - /// Takes a counter and then computes a HOTP value - /// - /// The timestamp to use for the HOTP calculation - /// a HOTP value - public string ComputeHOTP(long counter) - { - return this.Compute(counter, this.hashMode); - } + /// + /// Takes a time step and computes a HOTP code + /// + /// counter + /// The hash mode to use + /// HOTP calculated code + protected override string Compute(long counter, OtpHashMode mode) + { + var data = KeyUtilities.GetBigEndianBytes(counter); + var otp = CalculateOtp(data, mode); + return Digits(otp, HotpSize); + } - /// - /// Verify a value that has been provided with the calculated value - /// - /// the trial HOTP value - /// The counter value to verify/param> - /// True if there is a match. - public bool VerifyHotp(string hotp, long counter) + private static void VerifyParameters(int hotpSize) + { + if (hotpSize < 6) { - if(hotp == ComputeHOTP(counter)) - { - return true; - } - else - { - return false; - } + throw new ArgumentOutOfRangeException(nameof(hotpSize)); } - - /// - /// Takes a time step and computes a HOTP code - /// - /// counter - /// The hash mode to use - /// HOTP calculated code - protected override string Compute(long counter, OtpHashMode mode) + if (hotpSize > 8) { - var data = KeyUtilities.GetBigEndianBytes(counter); - var otp = this.CalculateOtp(data, mode); - return Digits(otp, this.hotpSize); + throw new ArgumentOutOfRangeException(nameof(hotpSize)); } } } diff --git a/src/Otp.NET/IKeyProvider.cs b/src/Otp.NET/IKeyProvider.cs index 611d086..8e54bb4 100644 --- a/src/Otp.NET/IKeyProvider.cs +++ b/src/Otp.NET/IKeyProvider.cs @@ -16,32 +16,31 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -namespace OtpNet +namespace OtpNet; + +/// +/// Interface used to interact with a key +/// +public interface IKeyProvider { /// - /// Interface used to interact with a key + /// Uses the key to get an HMAC using the specified algorithm and data /// - public interface IKeyProvider - { - /// - /// Uses the key to get an HMAC using the specified algorithm and data - /// - /// - /// This is a much better API than the previous API which would briefly expose the key for all derived types. - /// - /// Now a derived type could be bound to an HSM/smart card/etc if required and a lot of the security limitations - /// of in app/memory exposure of the key can be eliminated. - /// - /// The HMAC algorithm to use - /// The data used to compute the HMAC - /// HMAC of the key and data - byte[] ComputeHmac(OtpHashMode mode, byte[] data); - } + /// + /// This is a much better API than the previous API which would briefly expose the key for all derived types. + /// + /// Now a derived type could be bound to an HSM/smart card/etc if required and a lot of the security limitations + /// of in app/memory exposure of the key can be eliminated. + /// + /// The HMAC algorithm to use + /// The data used to compute the HMAC + /// HMAC of the key and data + byte[] ComputeHmac(OtpHashMode mode, byte[] data); } diff --git a/src/Otp.NET/InMemoryKey.cs b/src/Otp.NET/InMemoryKey.cs index 14dc694..e822ea7 100644 --- a/src/Otp.NET/InMemoryKey.cs +++ b/src/Otp.NET/InMemoryKey.cs @@ -16,7 +16,7 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER @@ -26,114 +26,109 @@ DEALINGS IN THE SOFTWARE. using System; using System.Security.Cryptography; -namespace OtpNet +namespace OtpNet; + +/// +/// Represents a key in memory +/// +/// +/// This will attempt to use the Windows data protection api to encrypt the key in memory. +/// However, this type favors working over memory protection. This is an attempt to minimize +/// exposure in memory, nothing more. This protection is flawed in many ways and is limited +/// to Windows. +/// +/// In order to use the key to compute an hmac it must be temporarily decrypted, used, +/// then re-encrypted. This does expose the key in memory for a time. If a memory dump occurs in this time +/// the plaintext key will be part of it. Furthermore, there are potentially +/// artifacts from the hmac computation, GC compaction, or any number of other leaks even after +/// the key is re-encrypted. +/// +/// This type favors working over memory protection. If the particular platform isn't supported then, +/// unless forced by modifying the IsPlatformSupported method, it will just store the key in a standard +/// byte array. +/// +public class InMemoryKey : IKeyProvider { + private readonly object _stateSync = new(); + private readonly byte[] _keyData; + private readonly int _keyLength; + /// - /// Represents a key in memory + /// Creates an instance of a key. /// - /// - /// This will attempt to use the Windows data protection api to encrypt the key in memory. - /// However, this type favors working over memory protection. This is an attempt to minimize - /// exposure in memory, nothing more. This protection is flawed in many ways and is limited - /// to Windows. - /// - /// In order to use the key to compute an hmac it must be temporarily decrypted, used, - /// then re-encrypted. This does expose the key in memory for a time. If a memory dump occurs in this time - /// the plaintext key will be part of it. Furthermore, there are potentially - /// artifacts from the hmac computation, GC compaction, or any number of other leaks even after - /// the key is re-encrypted. - /// - /// This type favors working over memory protection. If the particular platform isn't supported then, - /// unless forced by modifying the IsPlatformSupported method, it will just store the key in a standard - /// byte array. - /// - public class InMemoryKey : IKeyProvider + /// Plaintext key data + public InMemoryKey(byte[] key) { - static readonly object platformSupportSync = new object(); + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + if (key.Length <= 0) + { + throw new ArgumentException("The key must not be empty"); + } - readonly object stateSync = new object(); - readonly byte[] KeyData; - readonly int keyLength; + _keyLength = key.Length; + var paddedKeyLength = (int)Math.Ceiling((decimal)key.Length / (decimal)16) * 16; + _keyData = new byte[paddedKeyLength]; + Array.Copy(key, _keyData, key.Length); + } - /// - /// Creates an instance of a key. - /// - /// Plaintext key data - public InMemoryKey(byte[] key) + /// + /// Gets a copy of the plaintext key + /// + /// + /// This is internal rather than protected so that the tests can use this method + /// + /// Plaintext Key + internal byte[] GetCopyOfKey() + { + var plainKey = new byte[_keyLength]; + lock (_stateSync) { - if(!(key != null)) - throw new ArgumentNullException("key"); - if(!(key.Length > 0)) - throw new ArgumentException("The key must not be empty"); - - this.keyLength = key.Length; - int paddedKeyLength = (int)Math.Ceiling((decimal)key.Length / (decimal)16) * 16; - this.KeyData = new byte[paddedKeyLength]; - Array.Copy(key, this.KeyData, key.Length); + Array.Copy(_keyData, plainKey, _keyLength); } + return plainKey; + } - /// - /// Gets a copy of the plaintext key - /// - /// - /// This is internal rather than protected so that the tests can use this method - /// - /// Plaintext Key - internal byte[] GetCopyOfKey() + /// + /// Uses the key to get an HMAC using the specified algorithm and data + /// + /// The HMAC algorithm to use + /// The data used to compute the HMAC + /// HMAC of the key and data + public byte[] ComputeHmac(OtpHashMode mode, byte[] data) + { + byte[] hashedValue; + using (var hmac = CreateHmacHash(mode)) { - var plainKey = new byte[this.keyLength]; - lock(this.stateSync) + var key = GetCopyOfKey(); + try { - Array.Copy(this.KeyData, plainKey, this.keyLength); + hmac.Key = key; + hashedValue = hmac.ComputeHash(data); } - return plainKey; - } - - /// - /// Uses the key to get an HMAC using the specified algorithm and data - /// - /// The HMAC algorithm to use - /// The data used to compute the HMAC - /// HMAC of the key and data - public byte[] ComputeHmac(OtpHashMode mode, byte[] data) - { - byte[] hashedValue = null; - using(HMAC hmac = CreateHmacHash(mode)) + finally { - byte[] key = this.GetCopyOfKey(); - try - { - hmac.Key = key; - hashedValue = hmac.ComputeHash(data); - } - finally - { - KeyUtilities.Destroy(key); - } + KeyUtilities.Destroy(key); } - - return hashedValue; } - /// - /// Create an HMAC object for the specified algorithm - /// - private static HMAC CreateHmacHash(OtpHashMode otpHashMode) + return hashedValue; + } + + /// + /// Create an HMAC object for the specified algorithm + /// + private static HMAC CreateHmacHash(OtpHashMode otpHashMode) + { + HMAC hmacAlgorithm = otpHashMode switch { - HMAC hmacAlgorithm = null; - switch(otpHashMode) - { - case OtpHashMode.Sha256: - hmacAlgorithm = new HMACSHA256(); - break; - case OtpHashMode.Sha512: - hmacAlgorithm = new HMACSHA512(); - break; - default: //case OtpHashMode.Sha1: - hmacAlgorithm = new HMACSHA1(); - break; - } - return hmacAlgorithm; - } + OtpHashMode.Sha256 => new HMACSHA256(), + OtpHashMode.Sha512 => new HMACSHA512(), + // case OtpHashMode.Sha1: + _ => new HMACSHA1(), + }; + return hmacAlgorithm; } } \ No newline at end of file diff --git a/src/Otp.NET/KeyGeneration.cs b/src/Otp.NET/KeyGeneration.cs index 13ad7c4..143d35e 100644 --- a/src/Otp.NET/KeyGeneration.cs +++ b/src/Otp.NET/KeyGeneration.cs @@ -16,7 +16,7 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER @@ -26,88 +26,77 @@ DEALINGS IN THE SOFTWARE. using System; using System.Security.Cryptography; -namespace OtpNet +namespace OtpNet; + +/// +/// Helpers to work with keys +/// +public static class KeyGeneration { /// - /// Helpers to work with keys + /// Generates a random key in accordance with the RFC recommended length for each algorithm /// - public static class KeyGeneration + /// Key length + /// The generated key + public static byte[] GenerateRandomKey(int length) { - /// - /// Generates a random key in accordance with the RFC recommened length for each algorithm - /// - /// Key length - /// The generated key - public static byte[] GenerateRandomKey(int length) - { - byte[] key = new byte[length]; - using(var rnd = RandomNumberGenerator.Create()) - { - rnd.GetBytes(key); - return key; - } - } - - /// - /// Generates a random key in accordance with the RFC recommened length for each algorithm - /// - /// HashMode - /// Key - public static byte[] GenerateRandomKey(OtpHashMode mode = OtpHashMode.Sha1) - { - return GenerateRandomKey(LengthForMode(mode)); - } + var key = new byte[length]; + using var rnd = RandomNumberGenerator.Create(); + rnd.GetBytes(key); + return key; + } - /// - /// Uses the procedure defined in RFC 4226 section 7.5 to derive a key from the master key - /// - /// The master key from which to derive a device specific key - /// The public identifier that is unique to the authenticating device - /// The hash mode to use. This will determine the resulting key lenght. The default is sha-1 (as per the RFC) which is 20 bytes - /// Derived key - public static byte[] DeriveKeyFromMaster(IKeyProvider masterKey, byte[] publicIdentifier, OtpHashMode mode = OtpHashMode.Sha1) - { - if(masterKey == null) - throw new ArgumentNullException("masterKey"); - return masterKey.ComputeHmac(mode, publicIdentifier); - } + /// + /// Generates a random key in accordance with the RFC recommended length for each algorithm + /// + /// HashMode + /// Key + public static byte[] GenerateRandomKey(OtpHashMode mode = OtpHashMode.Sha1) + { + return GenerateRandomKey(LengthForMode(mode)); + } - /// - /// Uses the procedure defined in RFC 4226 section 7.5 to derive a key from the master key - /// - /// The master key from which to derive a device specific key - /// A serial number that is unique to the authenticating device - /// The hash mode to use. This will determine the resulting key lenght. The default is sha-1 (as per the RFC) which is 20 bytes - /// Derived key - public static byte[] DeriveKeyFromMaster(IKeyProvider masterKey, int serialNumber, OtpHashMode mode = OtpHashMode.Sha1) + /// + /// Uses the procedure defined in RFC 4226 section 7.5 to derive a key from the master key + /// + /// The master key from which to derive a device specific key + /// The public identifier that is unique to the authenticating device + /// The hash mode to use. This will determine the resulting key lenght. The default is sha-1 (as per the RFC) which is 20 bytes + /// Derived key + public static byte[] DeriveKeyFromMaster( + IKeyProvider masterKey, + byte[] publicIdentifier, + OtpHashMode mode = OtpHashMode.Sha1) + { + if (masterKey == null) { - return DeriveKeyFromMaster(masterKey, KeyUtilities.GetBigEndianBytes(serialNumber), mode); + throw new ArgumentNullException(nameof(masterKey)); } + return masterKey.ComputeHmac(mode, publicIdentifier); + } - private static HashAlgorithm GetHashAlgorithmForMode(OtpHashMode mode) - { - switch(mode) - { - case OtpHashMode.Sha256: - return SHA256.Create(); - case OtpHashMode.Sha512: - return SHA512.Create(); - default: //case OtpHashMode.Sha1: - return SHA1.Create(); - } - } + /// + /// Uses the procedure defined in RFC 4226 section 7.5 to derive a key from the master key + /// + /// The master key from which to derive a device specific key + /// A serial number that is unique to the authenticating device + /// The hash mode to use. This will determine the resulting key lenght. + /// The default is sha-1 (as per the RFC) which is 20 bytes + /// Derived key + public static byte[] DeriveKeyFromMaster( + IKeyProvider masterKey, + int serialNumber, + OtpHashMode mode = OtpHashMode.Sha1) => + DeriveKeyFromMaster(masterKey, KeyUtilities.GetBigEndianBytes(serialNumber), mode); - private static int LengthForMode(OtpHashMode mode) + private static int LengthForMode(OtpHashMode mode) + { + return mode switch { - switch(mode) - { - case OtpHashMode.Sha256: - return 32; - case OtpHashMode.Sha512: - return 64; - default: //case OtpHashMode.Sha1: - return 20; - } - } + OtpHashMode.Sha256 => 32, + OtpHashMode.Sha512 => 64, + // case OtpHashMode.Sha1: + _ => 20, + }; } } \ No newline at end of file diff --git a/src/Otp.NET/KeyUtilities.cs b/src/Otp.NET/KeyUtilities.cs index f81dfb3..b27165c 100644 --- a/src/Otp.NET/KeyUtilities.cs +++ b/src/Otp.NET/KeyUtilities.cs @@ -16,7 +16,7 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER @@ -25,57 +25,59 @@ DEALINGS IN THE SOFTWARE. using System; -namespace OtpNet +namespace OtpNet; + +/// +/// Some helper methods to perform common key functions +/// +internal class KeyUtilities { /// - /// Some helper methods to perform common key functions + /// Overwrite potentially sensitive data with random junk /// - internal class KeyUtilities + /// + /// Warning! + /// + /// This isn't foolproof by any means. The garbage collector could have moved the actual + /// location in memory to another location during a collection cycle and left the old data in place + /// simply marking it as available. We can't control this or even detect it. + /// This method is simply a good faith effort to limit the exposure of sensitive data in + /// memory as much as possible + /// + internal static void Destroy(byte[] sensitiveData) { - /// - /// Overwrite potentially sensitive data with random junk - /// - /// - /// Warning! - /// - /// This isn't foolproof by any means. The garbage collector could have moved the actual - /// location in memory to another location during a collection cycle and left the old data in place - /// simply marking it as available. We can't control this or even detect it. - /// This method is simply a good faith effort to limit the exposure of sensitive data in memory as much as possible - /// - internal static void Destroy(byte[] sensitiveData) + if (sensitiveData == null) { - if(sensitiveData == null) - throw new ArgumentNullException("sensitiveData"); - new Random().NextBytes(sensitiveData); + throw new ArgumentNullException(nameof(sensitiveData)); } + new Random().NextBytes(sensitiveData); + } - /// - /// converts a long into a big endian byte array. - /// - /// - /// RFC 4226 specifies big endian as the method for converting the counter to data to hash. - /// - static internal byte[] GetBigEndianBytes(long input) - { - // Since .net uses little endian numbers, we need to reverse the byte order to get big endian. - var data = BitConverter.GetBytes(input); - Array.Reverse(data); - return data; - } + /// + /// converts a long into a big endian byte array. + /// + /// + /// RFC 4226 specifies big endian as the method for converting the counter to data to hash. + /// + internal static byte[] GetBigEndianBytes(long input) + { + // Since .net uses little endian numbers, we need to reverse the byte order to get big endian. + var data = BitConverter.GetBytes(input); + Array.Reverse(data); + return data; + } - /// - /// converts an int into a big endian byte array. - /// - /// - /// RFC 4226 specifies big endian as the method for converting the counter to data to hash. - /// - static internal byte[] GetBigEndianBytes(int input) - { - // Since .net uses little endian numbers, we need to reverse the byte order to get big endian. - var data = BitConverter.GetBytes(input); - Array.Reverse(data); - return data; - } + /// + /// converts an int into a big endian byte array. + /// + /// + /// RFC 4226 specifies big endian as the method for converting the counter to data to hash. + /// + internal static byte[] GetBigEndianBytes(int input) + { + // Since .net uses little endian numbers, we need to reverse the byte order to get big endian. + var data = BitConverter.GetBytes(input); + Array.Reverse(data); + return data; } } diff --git a/src/Otp.NET/Otp.NET.csproj b/src/Otp.NET/Otp.NET.csproj index ba24252..01f84ec 100755 --- a/src/Otp.NET/Otp.NET.csproj +++ b/src/Otp.NET/Otp.NET.csproj @@ -1,29 +1,36 @@  - 1.2.2 - netstandard2.0;netstandard1.3;net45 + latest + 1.4.1 + net461;net5.0;net6.0;net7.0;net8.0;net9.0;net10.0;netstandard2.0 Otp.NET Otp.NET Kyle Spearrin Kyle Spearrin https://github.com/kspearrin/Otp.NET - https://raw.githubusercontent.com/kspearrin/Otp.NET/master/LICENSE.txt + LICENSE.txt An implementation of TOTP and HOTP which are commonly used for multi factor authentication by using a shared key between the client and the server to generate and verify one time use codes. For documentation and examples visit the project website on GitHub at https://github.com/kspearrin/Otp.NET An implementation of TOTP which is commonly used for multi factor authentication. otp,totp,hotp,2fa,two factor - http://i.imgur.com/XFfC64v.png + icon.png + README.md https://github.com/kspearrin/Otp.NET true - vs-signing-key.pfx OtpNet + True + + + + vs-signing-key.snk - - - + + + + diff --git a/src/Otp.NET/Otp.cs b/src/Otp.NET/Otp.cs index fcd23cf..37362a4 100644 --- a/src/Otp.NET/Otp.cs +++ b/src/Otp.NET/Otp.cs @@ -16,7 +16,7 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER @@ -24,137 +24,142 @@ DEALINGS IN THE SOFTWARE. */ using System; -using System.Security.Cryptography; -namespace OtpNet +namespace OtpNet; + +/// +/// An abstract class that contains common OTP calculations +/// +/// +/// https://tools.ietf.org/html/rfc4226 +/// +public abstract class Otp { /// - /// An abstract class that contains common OTP calculations + /// Secret key /// - /// - /// https://tools.ietf.org/html/rfc4226 - /// - public abstract class Otp - { - /// - /// Secret key - /// - protected readonly IKeyProvider secretKey; - - /// - /// The hash mode to use - /// - protected readonly OtpHashMode hashMode; - - /// - /// Constructor for the abstract class using an explicit secret key - /// - /// - /// The hash mode to use - public Otp(byte[] secretKey, OtpHashMode mode) - { - if(!(secretKey != null)) - throw new ArgumentNullException("secretKey"); - if(!(secretKey.Length > 0)) - throw new ArgumentException("secretKey empty"); + protected readonly IKeyProvider _secretKey; - // when passing a key into the constructor the caller may depend on the reference to the key remaining intact. - this.secretKey = new InMemoryKey(secretKey); + /// + /// The hash mode to use + /// + protected readonly OtpHashMode _hashMode; - this.hashMode = mode; - } + /// + /// Gets the hash mode to use + /// + public OtpHashMode HashMode => _hashMode; - /// - /// Constructor for the abstract class using a generic key provider - /// - /// - /// The hash mode to use - public Otp(IKeyProvider key, OtpHashMode mode) + /// + /// Constructor for the abstract class using an explicit secret key + /// + /// + /// The hash mode to use + public Otp(byte[] secretKey, OtpHashMode mode) + { + if (secretKey == null) + { + throw new ArgumentNullException(nameof(secretKey)); + } + if (secretKey.Length <= 0) { - if (key == null) - throw new ArgumentNullException("key"); + throw new ArgumentException("secretKey empty"); + } - this.secretKey = key; + // when passing a key into the constructor the caller may depend on the reference to the key remaining intact. + _secretKey = new InMemoryKey(secretKey); + _hashMode = mode; + } - this.hashMode = mode; - } + /// + /// Constructor for the abstract class using a generic key provider + /// + /// + /// The hash mode to use + public Otp(IKeyProvider key, OtpHashMode mode) + { + _secretKey = key ?? throw new ArgumentNullException(nameof(key)); + _hashMode = mode; + } - /// - /// An abstract definition of a compute method. Takes a counter and runs it through the derived algorithm. - /// - /// Counter or step - /// The hash mode to use - /// OTP calculated code - protected abstract string Compute(long counter, OtpHashMode mode); - - /// - /// Helper method that calculates OTPs - /// - protected internal long CalculateOtp(byte[] data, OtpHashMode mode) - { - byte[] hmacComputedHash = this.secretKey.ComputeHmac(mode, data); + /// + /// An abstract definition of a compute method. Takes a counter and runs it through the derived algorithm. + /// + /// Counter or step + /// The hash mode to use + /// OTP calculated code + protected abstract string Compute(long counter, OtpHashMode mode); + + /// + /// Helper method that calculates OTPs + /// + protected internal long CalculateOtp(byte[] data, OtpHashMode mode) + { + var hmacComputedHash = _secretKey.ComputeHmac(mode, data); - // The RFC has a hard coded index 19 in this value. - // This is the same thing but also accomodates SHA256 and SHA512 - // hmacComputedHash[19] => hmacComputedHash[hmacComputedHash.Length - 1] + // The RFC has a hard coded index 19 in this value. + // This is the same thing but also accomodates SHA256 and SHA512 + // hmacComputedHash[19] => hmacComputedHash[hmacComputedHash.Length - 1] - int offset = hmacComputedHash[hmacComputedHash.Length - 1] & 0x0F; - return (hmacComputedHash[offset] & 0x7f) << 24 - | (hmacComputedHash[offset + 1] & 0xff) << 16 - | (hmacComputedHash[offset + 2] & 0xff) << 8 - | (hmacComputedHash[offset + 3] & 0xff) % 1000000; - } + int offset = hmacComputedHash[hmacComputedHash.Length - 1] & 0x0F; + return (hmacComputedHash[offset] & 0x7f) << 24 + | (hmacComputedHash[offset + 1] & 0xff) << 16 + | (hmacComputedHash[offset + 2] & 0xff) << 8 + | (hmacComputedHash[offset + 3] & 0xff); + } - /// - /// truncates a number down to the specified number of digits - /// - protected internal static string Digits(long input, int digitCount) + /// + /// truncates a number down to the specified number of digits + /// + protected internal static string Digits(long input, int digitCount) + { + var truncatedValue = (int)input % (int)Math.Pow(10, digitCount); + return truncatedValue.ToString().PadLeft(digitCount, '0'); + } + + /// + /// Verify an OTP value + /// + /// The initial step to try + /// The value to verify + /// Output parameter that provides the step where the match was found. If no match was found it will be 0 + /// The window to verify + /// True if a match is found + protected bool Verify(long initialStep, string valueToVerify, out long matchedStep, VerificationWindow window) + { + if (window == null) { - var truncatedValue = ((int)input % (int)Math.Pow(10, digitCount)); - return truncatedValue.ToString().PadLeft(digitCount, '0'); + window = new VerificationWindow(); } - - /// - /// Verify an OTP value - /// - /// The initial step to try - /// The value to verify - /// Output parameter that provides the step where the match was found. If no match was found it will be 0 - /// The window to verify - /// True if a match is found - protected bool Verify(long initialStep, string valueToVerify, out long matchedStep, VerificationWindow window) + + foreach(var frame in window.ValidationCandidates(initialStep)) { - if(window == null) - window = new VerificationWindow(); - foreach(var frame in window.ValidationCandidates(initialStep)) + var comparisonValue = Compute(frame, _hashMode); + if(ValuesEqual(comparisonValue, valueToVerify)) { - var comparisonValue = this.Compute(frame, this.hashMode); - if(ValuesEqual(comparisonValue, valueToVerify)) - { - matchedStep = frame; - return true; - } + matchedStep = frame; + return true; } + } + + matchedStep = 0; + return false; + } - matchedStep = 0; + // Constant time comparison of two values + private bool ValuesEqual(string a, string b) + { + if(a.Length != b.Length) + { return false; } - // Constant time comparison of two values - private bool ValuesEqual(string a, string b) + var result = 0; + for(var i = 0; i < a.Length; i++) { - if(a.Length != b.Length) - { - return false; - } - - var result = 0; - for(int i = 0; i < a.Length; i++) - { - result |= a[i] ^ b[i]; - } - - return result == 0; + result |= a[i] ^ b[i]; } + + return result == 0; } } \ No newline at end of file diff --git a/src/Otp.NET/OtpHashMode.cs b/src/Otp.NET/OtpHashMode.cs index a613ff5..4ef1d7a 100644 --- a/src/Otp.NET/OtpHashMode.cs +++ b/src/Otp.NET/OtpHashMode.cs @@ -16,31 +16,30 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -namespace OtpNet +namespace OtpNet; + +/// +/// Indicates which HMAC hashing algorithm should be used +/// +public enum OtpHashMode { /// - /// Indicates which HMAC hashing algorithm should be used + /// Sha1 is used as the HMAC hashing algorithm + /// + Sha1, + /// + /// Sha256 is used as the HMAC hashing algorithm + /// + Sha256, + /// + /// Sha512 is used as the HMAC hashing algorithm /// - public enum OtpHashMode - { - /// - /// Sha1 is used as the HMAC hashing algorithm - /// - Sha1, - /// - /// Sha256 is used as the HMAC hashing algorithm - /// - Sha256, - /// - /// Sha512 is used as the HMAC hashing algorithm - /// - Sha512 - } + Sha512 } diff --git a/src/Otp.NET/OtpType.cs b/src/Otp.NET/OtpType.cs new file mode 100644 index 0000000..ce14913 --- /dev/null +++ b/src/Otp.NET/OtpType.cs @@ -0,0 +1,17 @@ +namespace OtpNet; +/// +/// Schema types for OTPs +/// +public enum OtpType +{ + /// + /// Time-based OTP + /// see https://datatracker.ietf.org/doc/html/rfc6238 + /// + Totp, + /// + /// HMAC-based (counter) OTP + /// see https://datatracker.ietf.org/doc/html/rfc4226 + /// + Hotp +} diff --git a/src/Otp.NET/OtpUri.cs b/src/Otp.NET/OtpUri.cs new file mode 100644 index 0000000..ca801e1 --- /dev/null +++ b/src/Otp.NET/OtpUri.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace OtpNet; + +// See https://github.com/google/google-authenticator/wiki/Key-Uri-Format +public class OtpUri +{ + /// + /// Create a new OTP Auth Uri + /// + public OtpUri( + OtpType schema, + string secret, + string user, + string issuer = null, + OtpHashMode algorithm = OtpHashMode.Sha1, + int digits = 6, + int period = 30, + long counter = 0) + { + _ = secret ?? throw new ArgumentNullException(nameof(secret)); + _ = user ?? throw new ArgumentNullException(nameof(user)); + if (digits < 0) + { + throw new ArgumentOutOfRangeException(nameof(digits)); + } + + Type = schema; + Secret = secret; + User = user; + Issuer = issuer; + Algorithm = algorithm; + Digits = digits; + + switch (Type) + { + case OtpType.Totp: + Period = period; + break; + case OtpType.Hotp: + Counter = counter; + break; + } + } + + /// + /// Create a new OTP Auth Uri + /// + public OtpUri( + OtpType schema, + byte[] secret, + string user, + string issuer = null, + OtpHashMode algorithm = OtpHashMode.Sha1, + int digits = 6, + int period = 30, + long counter = 0) + : this(schema, Base32Encoding.ToString(secret), user, issuer, + algorithm, digits, period, counter) + { } + + /// + /// What type of OTP is this uri for + /// + /// + public OtpType Type { get; private set; } + + /// + /// The secret parameter is an arbitrary key value encoded in Base32 according to RFC 3548. + /// The padding specified in RFC 3548 section 2.2 is not required and should be omitted. + /// + public string Secret { get; private set; } + + /// + /// Which account a key is associated with + /// + public string User { get; private set; } + + /// + /// The issuer parameter is a string value indicating the provider or service this account is + /// associated with, URL-encoded according to RFC 3986. + /// + public string Issuer { get; private set; } + + /// + /// The algorithm used by the generator + /// + public OtpHashMode Algorithm { get; private set; } + + /// + /// The amount of digits in the final code + /// + public int Digits { get; private set; } + + /// + /// The number of seconds that a code is valid. Only applies to TOTP, not HOTP + /// + public int Period { get; private set; } + + /// + /// Initial counter value for HOTP. This is ignored when using TOTP. + /// + public long Counter { get; private set; } + + /// + /// Generates a Uri according to the parameters + /// + /// a Uri according to the parameters + public Uri ToUri() + { + return new Uri(ToString()); + } + + /// + /// Generates a Uri String according to the parameters + /// + /// a Uri String according to the parameters + public override string ToString() + { + var parameters = new Dictionary + { + { "secret", Secret.TrimEnd('=') } + }; + + if (!string.IsNullOrWhiteSpace(Issuer)) + { + parameters.Add("issuer", Uri.EscapeDataString(Issuer)); + } + parameters.Add("algorithm", Algorithm.ToString().ToUpper()); + parameters.Add("digits", Digits.ToString()); + + switch (Type) + { + case OtpType.Totp: + parameters.Add("period", Period.ToString()); + break; + case OtpType.Hotp: + parameters.Add("counter", Counter.ToString()); + break; + } + + var uriBuilder = new StringBuilder("otpauth://"); + uriBuilder.Append(Type.ToString().ToLowerInvariant()); + uriBuilder.Append("/"); + + // The label + if (!string.IsNullOrWhiteSpace(Issuer)) + { + uriBuilder.Append(Uri.EscapeDataString(Issuer)); + uriBuilder.Append(":"); + } + uriBuilder.Append(Uri.EscapeDataString(User)); + + // Start of the parameters + uriBuilder.Append("?"); + foreach (var pair in parameters) + { + uriBuilder.Append(pair.Key); + uriBuilder.Append("="); + uriBuilder.Append(pair.Value); + uriBuilder.Append("&"); + } + // Remove last "&" + uriBuilder.Remove(uriBuilder.Length - 1, 1); + + return uriBuilder.ToString(); + } +} diff --git a/src/Otp.NET/TimeCorrection.cs b/src/Otp.NET/TimeCorrection.cs index 9654080..f88c5d6 100644 --- a/src/Otp.NET/TimeCorrection.cs +++ b/src/Otp.NET/TimeCorrection.cs @@ -16,7 +16,7 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER @@ -25,83 +25,71 @@ DEALINGS IN THE SOFTWARE. using System; -namespace OtpNet +namespace OtpNet; + +/// +/// Class to apply a correction factor to the system time +/// +/// +/// In cases where the local system time is incorrect it is preferable to simply correct the system time. +/// This class is provided to handle cases where it isn't possible for the client, the server, or both, +/// to be on the correct time. +/// +/// This library provides limited facilities to to ping NIST for a correct network time. +/// This class can be used manually however in cases where a server's time is off +/// and the consumer of this library can't control it. In that case create an instance of this class +/// and provide the current server time as the correct time parameter +/// +/// This class is immutable and therefore thread-safe +/// +public class TimeCorrection { /// - /// Class to apply a correction factor to the system time + /// An instance that provides no correction factor /// - /// - /// In cases where the local system time is incorrect it is preferable to simply correct the system time. - /// This class is provided to handle cases where it isn't possible for the client, the server, or both, to be on the correct time. - /// - /// This library provides limited facilities to to ping NIST for a correct network time. This class can be used manually however in cases where a server's time is off - /// and the consumer of this library can't control it. In that case create an instance of this class and provide the current server time as the correct time parameter - /// - /// This class is immutable and therefore threadsafe - /// - public class TimeCorrection - { - /// - /// An instance that provides no correction factor - /// - public static readonly TimeCorrection UncorrectedInstance = new TimeCorrection(); + public static readonly TimeCorrection UncorrectedInstance = new(); - private readonly TimeSpan timeCorrectionFactor; - - /// - /// Constructor used solely for the UncorrectedInstance static field to provide an instance without a correction factor. - /// - private TimeCorrection() - { - this.timeCorrectionFactor = TimeSpan.FromSeconds(0); - } + /// + /// Constructor used solely for the UncorrectedInstance static field to provide an instance without + /// a correction factor. + /// + private TimeCorrection() => CorrectionFactor = TimeSpan.FromSeconds(0); - /// - /// Creates a corrected time object by providing the known correct current UTC time. The current system UTC time will be used as the reference - /// - /// - /// This overload assumes UTC. If a base and reference time other than UTC are required then use the other overlaod. - /// - /// The current correct UTC time - public TimeCorrection(DateTime correctUtc) - { - this.timeCorrectionFactor = DateTime.UtcNow - correctUtc; - } + /// + /// Creates a corrected time object by providing the known correct current UTC time. + /// The current system UTC time will be used as the reference + /// + /// + /// This overload assumes UTC. If a base and reference time other than UTC are required + /// then use the other overload. + /// + /// The current correct UTC time + public TimeCorrection(DateTime correctUtc) => CorrectionFactor = DateTime.UtcNow - correctUtc; - /// - /// Creates a corrected time object by providing the known correct current time and the current reference time that needs correction - /// - /// The current correct time - /// The current reference time (time that will have the correction factor applied in subsequent calls) - public TimeCorrection(DateTime correctTime, DateTime referenceTime) - { - this.timeCorrectionFactor = referenceTime - correctTime; - } + /// + /// Creates a corrected time object by providing the known correct current time and the current reference + /// time that needs correction + /// + /// The current correct time + /// The current reference time (time that will have the correction + /// factor applied in subsequent calls) + public TimeCorrection(DateTime correctTime, DateTime referenceTime) => + CorrectionFactor = referenceTime - correctTime; - /// - /// Applies the correction factor to the reference time and returns a corrected time - /// - /// The reference time - /// The reference time with the correction factor applied - public DateTime GetCorrectedTime(DateTime referenceTime) - { - return referenceTime - timeCorrectionFactor; - } + /// + /// The timespan that is used to calculate a corrected time + /// + public TimeSpan CorrectionFactor { get; } - /// - /// Applies the correction factor to the current system UTC time and returns a corrected time - /// - public DateTime CorrectedUtcNow - { - get { return GetCorrectedTime(DateTime.UtcNow); } - } + /// + /// Applies the correction factor to the current system UTC time and returns a corrected time + /// + public DateTime CorrectedUtcNow => GetCorrectedTime(DateTime.UtcNow); - /// - /// The timespan that is used to calculate a corrected time - /// - public TimeSpan CorrectionFactor - { - get { return this.timeCorrectionFactor; } - } - } + /// + /// Applies the correction factor to the reference time and returns a corrected time + /// + /// The reference time + /// The reference time with the correction factor applied + public DateTime GetCorrectedTime(DateTime referenceTime) => referenceTime - CorrectionFactor; } \ No newline at end of file diff --git a/src/Otp.NET/Totp.cs b/src/Otp.NET/Totp.cs index 9ba2337..a6cfb9a 100644 --- a/src/Otp.NET/Totp.cs +++ b/src/Otp.NET/Totp.cs @@ -16,7 +16,7 @@ in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +FITNESS FOR A PARTICULAR PURPOSE AND NONINFINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER @@ -24,200 +24,255 @@ DEALINGS IN THE SOFTWARE. */ using System; -using System.Globalization; -namespace OtpNet +namespace OtpNet; + +/// +/// Calculate Timed-One-Time-Passwords (TOTP) from a secret key +/// +/// +/// The specifications for this are found in RFC 6238 +/// http://tools.ietf.org/html/rfc6238 +/// +public class Totp : Otp { /// - /// Calculate Timed-One-Time-Passwords (TOTP) from a secret key + /// The number of ticks as Measured at Midnight Jan 1st 1970; /// - /// - /// The specifications for this are found in RFC 6238 - /// http://tools.ietf.org/html/rfc6238 - /// - public class Totp : Otp + private const long UnicEpocTicks = 621355968000000000L; + + /// + /// A divisor for converting ticks to seconds + /// + private const long TicksToSeconds = 10000000L; + + /// + /// Create a TOTP instance + /// + /// The secret key to use in TOTP calculations + /// The time window step amount to use in calculating time windows. + /// The default is 30 as recommended in the RFC + /// The hash mode to use + /// The number of digits that the returning TOTP should have. The default is 6. + /// If required, a time correction can be specified to compensate of + /// an out of sync local clock + public Totp( + byte[] secretKey, + int step = 30, + OtpHashMode mode = OtpHashMode.Sha1, + int totpSize = 6, + TimeCorrection timeCorrection = null) + : base(secretKey, mode) { - /// - /// The number of ticks as Measured at Midnight Jan 1st 1970; - /// - const long unixEpochTicks = 621355968000000000L; - /// - /// A divisor for converting ticks to seconds - /// - const long ticksToSeconds = 10000000L; - - private readonly int step; - private readonly int totpSize; - private readonly TimeCorrection correctedTime; - - /// - /// Create a TOTP instance - /// - /// The secret key to use in TOTP calculations - /// The time window step amount to use in calculating time windows. The default is 30 as recommended in the RFC - /// The hash mode to use - /// The number of digits that the returning TOTP should have. The default is 6. - /// If required, a time correction can be specified to compensate of an out of sync local clock - public Totp(byte[] secretKey, int step = 30, OtpHashMode mode = OtpHashMode.Sha1, int totpSize = 6, TimeCorrection timeCorrection = null) - : base(secretKey, mode) - { - VerifyParameters(step, totpSize); + VerifyParameters(step, totpSize); - this.step = step; - this.totpSize = totpSize; + Step = step; + TotpSize = totpSize; - // we never null check the corrected time object. Since it's readonly, we'll ensure that it isn't null here and provide neatral functionality in this case. - this.correctedTime = timeCorrection ?? TimeCorrection.UncorrectedInstance; - } + // we never null check the corrected time object. Since it's readonly, we'll + // ensure that it isn't null here and provide neutral functionality in this case. + TimeCorrection = timeCorrection ?? TimeCorrection.UncorrectedInstance; + } - /// - /// Create a TOTP instance - /// - /// The secret key to use in TOTP calculations - /// The time window step amount to use in calculating time windows. The default is 30 as recommended in the RFC - /// The hash mode to use - /// The number of digits that the returning TOTP should have. The default is 6. - /// If required, a time correction can be specified to compensate of an out of sync local clock - public Totp(IKeyProvider key, int step = 30, OtpHashMode mode = OtpHashMode.Sha1, int totpSize = 6, TimeCorrection timeCorrection = null) - : base(key, mode) - { - VerifyParameters(step, totpSize); + /// + /// Create a TOTP instance + /// + /// The secret key to use in TOTP calculations + /// The time window step amount to use in calculating time windows. + /// The default is 30 as recommended in the RFC + /// The hash mode to use + /// The number of digits that the returning TOTP should have. + /// The default is 6. + /// If required, a time correction can be specified to compensate + /// of an out of sync local clock + public Totp( + IKeyProvider key, + int step = 30, + OtpHashMode mode = OtpHashMode.Sha1, + int totpSize = 6, + TimeCorrection timeCorrection = null) + : base(key, mode) + { + VerifyParameters(step, totpSize); - this.step = step; - this.totpSize = totpSize; + Step = step; + TotpSize = totpSize; - // we never null check the corrected time object. Since it's readonly, we'll ensure that it isn't null here and provide neatral functionality in this case. - this.correctedTime = timeCorrection ?? TimeCorrection.UncorrectedInstance; - } + // we never null check the corrected time object. + // Since it's readonly, we'll ensure that it isn't null here and provide neutral functionality in this case. + TimeCorrection = timeCorrection ?? TimeCorrection.UncorrectedInstance; + } - private static void VerifyParameters(int step, int totpSize) - { - if(!(step > 0)) - throw new ArgumentOutOfRangeException("step"); - if(!(totpSize > 0)) - throw new ArgumentOutOfRangeException("totpSize"); - if(!(totpSize <= 10)) - throw new ArgumentOutOfRangeException("totpSize"); - } + /// + /// Gets the time window step amount to use in calculating time windows + /// + public int Step { get; private set; } - /// - /// Takes a timestamp and applies correction (if provided) and then computes a TOTP value - /// - /// The timestamp to use for the TOTP calculation - /// a TOTP value - public string ComputeTotp(DateTime timestamp) - { - return ComputeTotpFromSpecificTime(this.correctedTime.GetCorrectedTime(timestamp)); - } + /// + /// Gets the number of digits that the returning TOTP should have + /// + public int TotpSize { get; private set; } - /// - /// Takes a timestamp and computes a TOTP value for corrected UTC now - /// - /// - /// It will be corrected against a corrected UTC time using the provided time correction. If none was provided then simply the current UTC will be used. - /// - /// a TOTP value - public string ComputeTotp() - { - return this.ComputeTotpFromSpecificTime(this.correctedTime.CorrectedUtcNow); - } + /// + /// Gets the time correction to componensate out of sync local clock + /// + public TimeCorrection TimeCorrection { get; private set; } - private string ComputeTotpFromSpecificTime(DateTime timestamp) - { - var window = CalculateTimeStepFromTimestamp(timestamp); - return this.Compute(window, this.hashMode); - } + /// + /// Takes a timestamp and applies correction (if provided) and then computes a TOTP value + /// + /// The timestamp to use for the TOTP calculation + /// a TOTP value + public string ComputeTotp(DateTime timestamp) => + ComputeTotpFromSpecificTime(TimeCorrection.GetCorrectedTime(timestamp)); - /// - /// Verify a value that has been provided with the calculated value. - /// - /// - /// It will be corrected against a corrected UTC time using the provided time correction. If none was provided then simply the current UTC will be used. - /// - /// the trial TOTP value - /// - /// This is an output parameter that gives that time step that was used to find a match. - /// This is useful in cases where a TOTP value should only be used once. This value is a unique identifier of the - /// time step (not the value) that can be used to prevent the same step from being used multiple times - /// - /// The window of steps to verify - /// True if there is a match. - public bool VerifyTotp(string totp, out long timeStepMatched, VerificationWindow window = null) - { - return this.VerifyTotpForSpecificTime(this.correctedTime.CorrectedUtcNow, totp, window, out timeStepMatched); - } + /// + /// Takes a timestamp and computes a TOTP value for corrected UTC now + /// + /// + /// It will be corrected against a corrected UTC time using the provided time correction. + /// If none was provided then simply the current UTC will be used. + /// + /// a TOTP value + public string ComputeTotp() => ComputeTotpFromSpecificTime(TimeCorrection.CorrectedUtcNow); - /// - /// Verify a value that has been provided with the calculated value - /// - /// The timestamp to use - /// the trial TOTP value - /// - /// This is an output parameter that gives that time step that was used to find a match. - /// This is usefule in cases where a TOTP value should only be used once. This value is a unique identifier of the - /// time step (not the value) that can be used to prevent the same step from being used multiple times - /// - /// The window of steps to verify - /// True if there is a match. - public bool VerifyTotp(DateTime timestamp, string totp, out long timeStepMatched, VerificationWindow window = null) - { - return this.VerifyTotpForSpecificTime(this.correctedTime.GetCorrectedTime(timestamp), totp, window, out timeStepMatched); - } + /// + /// Verify a value that has been provided with the calculated value. + /// + /// + /// It will be corrected against a corrected UTC time using the provided time correction. + /// If none was provided then simply the current UTC will be used. + /// + /// the trial TOTP value + /// + /// This is an output parameter that gives that time step that was used to find a match. + /// This is useful in cases where a TOTP value should only be used once. This value is a unique identifier of the + /// time step (not the value) that can be used to prevent the same step from being used multiple times + /// + /// The window of steps to verify + /// True if there is a match. + public bool VerifyTotp(string totp, out long timeStepMatched, VerificationWindow window = null) => + VerifyTotpForSpecificTime(TimeCorrection.CorrectedUtcNow, totp, window, out timeStepMatched); - private bool VerifyTotpForSpecificTime(DateTime timestamp, string totp, VerificationWindow window, out long timeStepMatched) - { - var initialStep = CalculateTimeStepFromTimestamp(timestamp); - return this.Verify(initialStep, totp, out timeStepMatched, window); - } + /// + /// Verify a value that has been provided with the calculated value + /// + /// The timestamp to use + /// the trial TOTP value + /// + /// This is an output parameter that gives that time step that was used to find a match. + /// This is useful in cases where a TOTP value should only be used once. + /// This value is a unique identifier of the time step (not the value) that can be used + /// to prevent the same step from being used multiple times + /// + /// The window of steps to verify + /// True if there is a match. + public bool VerifyTotp( + DateTime timestamp, + string totp, + out long timeStepMatched, + VerificationWindow window = null) => + VerifyTotpForSpecificTime( + TimeCorrection.GetCorrectedTime(timestamp), + totp, + window, + out timeStepMatched); - /// - /// Takes a timestamp and calculates a time step - /// - private long CalculateTimeStepFromTimestamp(DateTime timestamp) - { - var unixTimestamp = (timestamp.Ticks - unixEpochTicks) / ticksToSeconds; - var window = unixTimestamp / (long)this.step; - return window; - } + /// + /// Remaining seconds in current window based on UtcNow + /// + /// + /// It will be corrected against a corrected UTC time using the provided time correction. + /// If none was provided then simply the current UTC will be used. + /// + /// Number of remaining seconds + public int RemainingSeconds() + { + return RemainingSecondsForSpecificTime(TimeCorrection.CorrectedUtcNow); + } - /// - /// Remaining seconds in current window based on UtcNow - /// - /// - /// It will be corrected against a corrected UTC time using the provided time correction. If none was provided then simply the current UTC will be used. - /// - /// Number of remaining seconds - public int RemainingSeconds() - { - return RemainingSecondsForSpecificTime(this.correctedTime.CorrectedUtcNow); - } + /// + /// Remaining seconds in current window + /// + /// The timestamp + /// Number of remaining seconds + public int RemainingSeconds(DateTime timestamp) => + RemainingSecondsForSpecificTime(TimeCorrection.GetCorrectedTime(timestamp)); + + /// + /// Start of the current window based on UtcNow + /// + /// + /// It will be corrected against a corrected UTC time using the provided time correction. + /// If none was provided then simply the current UTC will be used. + /// + /// Start of the current window + public DateTime WindowStart() + { + return WindowStartForSpecificTime(TimeCorrection.CorrectedUtcNow); + } - /// - /// Remaining seconds in current window - /// - /// The timestamp - /// Number of remaining seconds - public int RemainingSeconds(DateTime timestamp) + public DateTime WindowStart(DateTime timestamp) => + WindowStartForSpecificTime(TimeCorrection.GetCorrectedTime(timestamp)); + + /// + /// Takes a time step and computes a TOTP code + /// + /// time step + /// The hash mode to use + /// TOTP calculated code + protected override string Compute(long counter, OtpHashMode mode) + { + var data = KeyUtilities.GetBigEndianBytes(counter); + var otp = CalculateOtp(data, mode); + return Digits(otp, TotpSize); + } + + private static void VerifyParameters(int step, int totpSize) + { + if (step <= 0) { - return RemainingSecondsForSpecificTime(this.correctedTime.GetCorrectedTime(timestamp)); + throw new ArgumentOutOfRangeException(nameof(step)); } - - private int RemainingSecondsForSpecificTime(DateTime timestamp) + if (totpSize <= 0) { - return this.step - (int)(((timestamp.Ticks - unixEpochTicks) / ticksToSeconds) % this.step); + throw new ArgumentOutOfRangeException(nameof(totpSize)); } - - /// - /// Takes a time step and computes a TOTP code - /// - /// time step - /// The hash mode to use - /// TOTP calculated code - protected override string Compute(long counter, OtpHashMode mode) + if (totpSize > 10) { - var data = KeyUtilities.GetBigEndianBytes(counter); - var otp = this.CalculateOtp(data, mode); - return Digits(otp, this.totpSize); + throw new ArgumentOutOfRangeException(nameof(totpSize)); } } + + private string ComputeTotpFromSpecificTime(DateTime timestamp) + { + var window = CalculateTimeStepFromTimestamp(timestamp); + return Compute(window, _hashMode); + } + + private bool VerifyTotpForSpecificTime( + DateTime timestamp, + string totp, + VerificationWindow window, + out long timeStepMatched) + { + var initialStep = CalculateTimeStepFromTimestamp(timestamp); + return Verify(initialStep, totp, out timeStepMatched, window); + } + + /// + /// Takes a timestamp and calculates a time step + /// + private long CalculateTimeStepFromTimestamp(DateTime timestamp) + { + var unixTimestamp = (timestamp.Ticks - UnicEpocTicks) / TicksToSeconds; + var window = unixTimestamp / (long)Step; + return window; + } + + private int RemainingSecondsForSpecificTime(DateTime timestamp) => + Step - (int)(((timestamp.Ticks - UnicEpocTicks) / TicksToSeconds) % Step); + + private DateTime WindowStartForSpecificTime(DateTime timestamp) => + timestamp.AddTicks(-(timestamp.Ticks - UnicEpocTicks) % (TicksToSeconds * Step)); } \ No newline at end of file diff --git a/src/Otp.NET/VerificationWindow.cs b/src/Otp.NET/VerificationWindow.cs index d3d8591..f6af050 100644 --- a/src/Otp.NET/VerificationWindow.cs +++ b/src/Otp.NET/VerificationWindow.cs @@ -25,50 +25,54 @@ DEALINGS IN THE SOFTWARE. using System.Collections.Generic; -namespace OtpNet +namespace OtpNet; + +/// +/// A verification window +/// +public class VerificationWindow { + private readonly int _previous; + private readonly int _future; + /// - /// A verification window + /// Create an instance of a verification window /// - public class VerificationWindow + /// The number of previous frames to accept + /// The number of future frames to accept + public VerificationWindow(int previous = 0, int future = 0) { - private readonly int previous; - private readonly int future; - - /// - /// Create an instance of a verification window - /// - /// The number of previous frames to accept - /// The number of future frames to accept - public VerificationWindow(int previous = 0, int future = 0) - { - this.previous = previous; - this.future = future; - } + _previous = previous; + _future = future; + } - /// - /// Gets an enumberable of all the possible validation candidates - /// - /// The initial frame to validate - /// Enumberable of all possible frames that need to be validated - public IEnumerable ValidationCandidates(long initialFrame) + /// + /// Gets an enumerable of all the possible validation candidates + /// + /// The initial frame to validate + /// Enumerable of all possible frames that need to be validated + public IEnumerable ValidationCandidates(long initialFrame) + { + yield return initialFrame; + for (var i = 1; i <= _previous; i++) { - yield return initialFrame; - for(int i = 1; i <= previous; i++) + var val = initialFrame - i; + if (val < 0) { - var val = initialFrame - i; - if(val < 0) - break; - yield return val; + break; } - - for(int i = 1; i <= future; i++) - yield return initialFrame + i; + yield return val; } - /// - /// The verification window that accomodates network delay that is recommended in the RFC - /// - public static readonly VerificationWindow RfcSpecifiedNetworkDelay = new VerificationWindow(previous: 1, future: 1); + for (var i = 1; i <= _future; i++) + { + yield return initialFrame + i; + } } + + /// + /// The verification window that accommodates network delay that is recommended in the RFC + /// + public static readonly VerificationWindow RfcSpecifiedNetworkDelay = + new VerificationWindow(previous: 1, future: 1); } diff --git a/test/Otp.NET.Test/HotpTest.cs b/test/Otp.NET.Test/HotpTest.cs index 041e33d..5c77604 100644 --- a/test/Otp.NET.Test/HotpTest.cs +++ b/test/Otp.NET.Test/HotpTest.cs @@ -1,44 +1,43 @@ -using Moq; +using Moq; using NUnit.Framework; -namespace OtpNet.Test +namespace OtpNet.Test; + +[TestFixture] +public class HotpTest { - [TestFixture] - public class HotpTest + private static readonly byte[] _rfc4226Secret = { + 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, + 0x39, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, + 0x37, 0x38, 0x39, 0x30 + }; + + [TestCase(OtpHashMode.Sha1, 0, "755224")] + [TestCase(OtpHashMode.Sha1, 1, "287082")] + [TestCase(OtpHashMode.Sha1, 2, "359152")] + [TestCase(OtpHashMode.Sha1, 3, "969429")] + [TestCase(OtpHashMode.Sha1, 4, "338314")] + [TestCase(OtpHashMode.Sha1, 5, "254676")] + [TestCase(OtpHashMode.Sha1, 6, "287922")] + [TestCase(OtpHashMode.Sha1, 7, "162583")] + [TestCase(OtpHashMode.Sha1, 8, "399871")] + [TestCase(OtpHashMode.Sha1, 9, "520489")] + public void ComputeHOTPRfc4226Test(OtpHashMode hash, long counter, string expectedOtp) { - private static readonly byte[] rfc4226Secret = new byte[] { - 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, - 0x39, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, - 0x37, 0x38, 0x39, 0x30 - }; + var otpCalc = new Hotp(_rfc4226Secret, hash, expectedOtp.Length); + var otp = otpCalc.ComputeHOTP(counter); + Assert.That(otp, Is.EqualTo(expectedOtp)); + } - [TestCase(OtpHashMode.Sha1, 0, "755224")] - [TestCase(OtpHashMode.Sha1, 1, "287082")] - [TestCase(OtpHashMode.Sha1, 2, "359152")] - [TestCase(OtpHashMode.Sha1, 3, "969429")] - [TestCase(OtpHashMode.Sha1, 4, "338314")] - [TestCase(OtpHashMode.Sha1, 5, "254676")] - [TestCase(OtpHashMode.Sha1, 6, "287922")] - [TestCase(OtpHashMode.Sha1, 7, "162583")] - [TestCase(OtpHashMode.Sha1, 8, "399871")] - [TestCase(OtpHashMode.Sha1, 9, "520489")] - public void ComputeHOTPRfc4226Test(OtpHashMode hash, long counter, string expectedOtp) - { - Hotp otpCalc = new Hotp(rfc4226Secret, hash, expectedOtp.Length); - string otp = otpCalc.ComputeHOTP(counter); - Assert.That(otp, Is.EqualTo(expectedOtp)); - } - - [Test()] - public void ContructorWithKeyProviderTest() - { - //Mock a key provider which always returns an all-zero HMAC (causing an all-zero OTP) - Mock keyMock = new Mock(); - keyMock.Setup(key => key.ComputeHmac(It.Is(m => m == OtpHashMode.Sha1), It.IsAny())).Returns(new byte[20]); + [Test] + public void ContructorWithKeyProviderTest() + { + // Mock a key provider which always returns an all-zero HMAC (causing an all-zero OTP) + var keyMock = new Mock(); + keyMock.Setup(key => key.ComputeHmac(It.Is(m => m == OtpHashMode.Sha1), It.IsAny())).Returns(new byte[20]); - var otp = new Hotp(keyMock.Object, OtpHashMode.Sha1, 6); - Assert.That(otp.ComputeHOTP(0), Is.EqualTo("000000")); - Assert.That(otp.ComputeHOTP(1), Is.EqualTo("000000")); - } + var otp = new Hotp(keyMock.Object, OtpHashMode.Sha1, 6); + Assert.That(otp.ComputeHOTP(0), Is.EqualTo("000000")); + Assert.That(otp.ComputeHOTP(1), Is.EqualTo("000000")); } } diff --git a/test/Otp.NET.Test/Otp.NET.Test.csproj b/test/Otp.NET.Test/Otp.NET.Test.csproj index f55592a..3233f8b 100644 --- a/test/Otp.NET.Test/Otp.NET.Test.csproj +++ b/test/Otp.NET.Test/Otp.NET.Test.csproj @@ -1,16 +1,16 @@  - netcoreapp2.2 + net10.0 false OtpNet.Test - - - - + + + + diff --git a/test/Otp.NET.Test/OtpUriTest.cs b/test/Otp.NET.Test/OtpUriTest.cs new file mode 100644 index 0000000..4e4a574 --- /dev/null +++ b/test/Otp.NET.Test/OtpUriTest.cs @@ -0,0 +1,26 @@ +using NUnit.Framework; + +namespace OtpNet.Test; + +[TestFixture] +public class OtpUriTest +{ + private const string BaseSecret = "JBSWY3DPEHPK3PXP"; + private const string BaseUser = "alice@google.com"; + private const string BaseIssuer = "ACME Co"; + + [TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha1, 6, 30, 0, + "otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30")] + [TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha256, 6, 30, 0, + "otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA256&digits=6&period=30")] + [TestCase(BaseSecret, OtpType.Totp, BaseUser, BaseIssuer, OtpHashMode.Sha512, 6, 30, 0, + "otpauth://totp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA512&digits=6&period=30")] + [TestCase(BaseSecret, OtpType.Hotp, BaseUser, BaseIssuer, OtpHashMode.Sha512, 6, 30, 0, + "otpauth://hotp/ACME%20Co:alice%40google.com?secret=JBSWY3DPEHPK3PXP&issuer=ACME%20Co&algorithm=SHA512&digits=6&counter=0")] + public void GenerateOtpUriTest(string secret, OtpType otpType, string user, string issuer, + OtpHashMode hash, int digits, int period, int counter, string expectedUri) + { + var uriString = new OtpUri(otpType, secret, user, issuer, hash, digits, period, counter).ToString(); + Assert.That(uriString, Is.EqualTo(expectedUri)); + } +} diff --git a/test/Otp.NET.Test/TotpTest.cs b/test/Otp.NET.Test/TotpTest.cs index 5e5299a..ea4d69b 100644 --- a/test/Otp.NET.Test/TotpTest.cs +++ b/test/Otp.NET.Test/TotpTest.cs @@ -3,50 +3,55 @@ using Moq; using NUnit.Framework; -namespace OtpNet.Test +namespace OtpNet.Test; + +[TestFixture] +public class TotpTest { - [TestFixture()] - public class TotpTest - { - private const string rfc6238SecretSha1 = "12345678901234567890"; - private const string rfc6238SecretSha256 = "12345678901234567890123456789012"; - private const string rfc6238SecretSha512 = "1234567890123456789012345678901234567890123456789012345678901234"; + // Base32 = GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ + private const string Rfc6238SecretSha1 = "12345678901234567890"; + // Base32 = GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA + private const string Rfc6238SecretSha256 = "12345678901234567890123456789012"; + // Base32 = GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA + private const string Rfc6238SecretSha512 = "1234567890123456789012345678901234567890123456789012345678901234"; - [TestCase(rfc6238SecretSha1, OtpHashMode.Sha1, 59, "94287082")] - [TestCase(rfc6238SecretSha256, OtpHashMode.Sha256, 59, "46119246")] - [TestCase(rfc6238SecretSha512, OtpHashMode.Sha512, 59, "90693936")] - [TestCase(rfc6238SecretSha1, OtpHashMode.Sha1, 1111111109, "07081804")] - [TestCase(rfc6238SecretSha256, OtpHashMode.Sha256, 1111111109, "68084774")] - [TestCase(rfc6238SecretSha512, OtpHashMode.Sha512, 1111111109, "25091201")] - [TestCase(rfc6238SecretSha1, OtpHashMode.Sha1, 1111111111, "14050471")] - [TestCase(rfc6238SecretSha256, OtpHashMode.Sha256, 1111111111, "67062674")] - [TestCase(rfc6238SecretSha512, OtpHashMode.Sha512, 1111111111, "99943326")] - [TestCase(rfc6238SecretSha1, OtpHashMode.Sha1, 1234567890, "89005924")] - [TestCase(rfc6238SecretSha256, OtpHashMode.Sha256, 1234567890, "91819424")] - [TestCase(rfc6238SecretSha512, OtpHashMode.Sha512, 1234567890, "93441116")] - [TestCase(rfc6238SecretSha1, OtpHashMode.Sha1, 2000000000, "69279037")] - [TestCase(rfc6238SecretSha256, OtpHashMode.Sha256, 2000000000, "90698825")] - [TestCase(rfc6238SecretSha512, OtpHashMode.Sha512, 2000000000, "38618901")] - [TestCase(rfc6238SecretSha1, OtpHashMode.Sha1, 20000000000, "65353130")] - [TestCase(rfc6238SecretSha256, OtpHashMode.Sha256, 20000000000, "77737706")] - [TestCase(rfc6238SecretSha512, OtpHashMode.Sha512, 20000000000, "47863826")] - public void ComputeTOTPTest(string secret, OtpHashMode hash, long timestamp, string expectedOtp) - { - Totp otpCalc = new Totp(Encoding.UTF8.GetBytes(secret), 30, hash, expectedOtp.Length); - DateTime time = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; - string otp = otpCalc.ComputeTotp(time); - Assert.That(otp, Is.EqualTo(expectedOtp)); - } + [TestCase(Rfc6238SecretSha1, OtpHashMode.Sha1, 59, "94287082")] + [TestCase(Rfc6238SecretSha256, OtpHashMode.Sha256, 59, "46119246")] + [TestCase(Rfc6238SecretSha512, OtpHashMode.Sha512, 59, "90693936")] + [TestCase(Rfc6238SecretSha1, OtpHashMode.Sha1, 1111111109, "07081804")] + [TestCase(Rfc6238SecretSha256, OtpHashMode.Sha256, 1111111109, "68084774")] + [TestCase(Rfc6238SecretSha512, OtpHashMode.Sha512, 1111111109, "25091201")] + [TestCase(Rfc6238SecretSha1, OtpHashMode.Sha1, 1111111111, "14050471")] + [TestCase(Rfc6238SecretSha256, OtpHashMode.Sha256, 1111111111, "67062674")] + [TestCase(Rfc6238SecretSha512, OtpHashMode.Sha512, 1111111111, "99943326")] + [TestCase(Rfc6238SecretSha1, OtpHashMode.Sha1, 1234567890, "89005924")] + [TestCase(Rfc6238SecretSha256, OtpHashMode.Sha256, 1234567890, "91819424")] + [TestCase(Rfc6238SecretSha512, OtpHashMode.Sha512, 1234567890, "93441116")] + [TestCase(Rfc6238SecretSha1, OtpHashMode.Sha1, 2000000000, "69279037")] + [TestCase(Rfc6238SecretSha256, OtpHashMode.Sha256, 2000000000, "90698825")] + [TestCase(Rfc6238SecretSha512, OtpHashMode.Sha512, 2000000000, "38618901")] + [TestCase(Rfc6238SecretSha1, OtpHashMode.Sha1, 20000000000, "65353130")] + [TestCase(Rfc6238SecretSha256, OtpHashMode.Sha256, 20000000000, "77737706")] + [TestCase(Rfc6238SecretSha512, OtpHashMode.Sha512, 20000000000, "47863826")] + [TestCase(Rfc6238SecretSha1, OtpHashMode.Sha1, 20000000000, "353130")] + [TestCase(Rfc6238SecretSha256, OtpHashMode.Sha256, 20000000000, "737706")] + [TestCase(Rfc6238SecretSha512, OtpHashMode.Sha512, 20000000000, "863826")] + public void ComputeTOTPTest(string secret, OtpHashMode hash, long timestamp, string expectedOtp) + { + var otpCalc = new Totp(Encoding.UTF8.GetBytes(secret), 30, hash, expectedOtp.Length); + var time = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; + var otp = otpCalc.ComputeTotp(time); + Assert.That(otp, Is.EqualTo(expectedOtp)); + } - [Test()] - public void ContructorWithKeyProviderTest() - { - //Mock a key provider which always returns an all-zero HMAC (causing an all-zero OTP) - Mock keyMock = new Mock(); - keyMock.Setup(key => key.ComputeHmac(It.Is(m => m == OtpHashMode.Sha1), It.IsAny())).Returns(new byte[20]); + [Test] + public void ContructorWithKeyProviderTest() + { + // Mock a key provider which always returns an all-zero HMAC (causing an all-zero OTP) + var keyMock = new Mock(); + keyMock.Setup(key => key.ComputeHmac(It.Is(m => m == OtpHashMode.Sha1), It.IsAny())).Returns(new byte[20]); - var otp = new Totp(keyMock.Object, 30, OtpHashMode.Sha1, 6); - Assert.That(otp.ComputeTotp(), Is.EqualTo("000000")); - } + var otp = new Totp(keyMock.Object, 30, OtpHashMode.Sha1, 6); + Assert.That(otp.ComputeTotp(), Is.EqualTo("000000")); } }