diff --git a/src/MaksIT.Core.Tests/Security/Base32EncoderTests.cs b/src/MaksIT.Core.Tests/Security/Base32EncoderTests.cs new file mode 100644 index 0000000..52facca --- /dev/null +++ b/src/MaksIT.Core.Tests/Security/Base32EncoderTests.cs @@ -0,0 +1,53 @@ +using System.Text; + +using MaksIT.Core.Security; + + +namespace MaksIT.Core.Tests.Security; + +public class Base32EncoderTests { + [Fact] + public void Encode_ValidInput_ReturnsExpectedBase32String() { + // Arrange + var input = Encoding.UTF8.GetBytes("Hello World"); + var expected = "JBSWY3DPEBLW64TMMQ======"; + + // Act + var result = Base32Encoder.Encode(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void Decode_ValidBase32String_ReturnsExpectedByteArray() { + // Arrange + var input = "JBSWY3DPEBLW64TMMQ======"; + var expected = Encoding.UTF8.GetBytes("Hello World"); + + // Act + var result = Base32Encoder.Decode(input); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + public void Decode_InvalidBase32String_ThrowsFormatException() { + // Act & Assert + Assert.Throws(() => Base32Encoder.Decode("InvalidBase32String")); + } + + [Fact] + public void EncodeDecode_RoundTrip_ReturnsOriginalData() { + // Arrange + var originalData = Encoding.UTF8.GetBytes("RoundTripTest"); + + // Act + var encoded = Base32Encoder.Encode(originalData); + var decoded = Base32Encoder.Decode(encoded); + + // Assert + Assert.Equal(originalData, decoded); + } +} diff --git a/src/MaksIT.Core.Tests/Security/TtopGeneratorTests.cs b/src/MaksIT.Core.Tests/Security/TtopGeneratorTests.cs new file mode 100644 index 0000000..aaa82f0 --- /dev/null +++ b/src/MaksIT.Core.Tests/Security/TtopGeneratorTests.cs @@ -0,0 +1,69 @@ +using MaksIT.Core.Security; +using System; +using Xunit; + +namespace MaksIT.Core.Tests.Security { + public class TotpGeneratorTests { + private const string Base32Secret = "JBSWY3DPEHPK3PXP"; // Example Base32 secret + + [Fact] + public void Validate_ValidTotpCode_ReturnsTrue() { + // Arrange + var timestep = TotpGenerator.GetCurrentTimeStepNumber(); + var validTotpCode = TotpGenerator.Generate(Base32Secret, timestep); + + // Act + var isValid = TotpGenerator.Validate(validTotpCode, Base32Secret); + + // Assert + Assert.True(isValid); + } + + [Fact] + public void Validate_InvalidTotpCode_ReturnsFalse() { + // Arrange + var invalidTotpCode = "123456"; // Example invalid TOTP code + + // Act + var isValid = TotpGenerator.Validate(invalidTotpCode, Base32Secret); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void Validate_TotpCodeWithTimeTolerance_ReturnsTrue() { + // Arrange + var timestep = TotpGenerator.GetCurrentTimeStepNumber() - 1; // One timestep in the past + var validTotpCode = TotpGenerator.Generate(Base32Secret, timestep); + + // Act + var isValid = TotpGenerator.Validate(validTotpCode, Base32Secret, timeTolerance: 1); + + // Assert + Assert.True(isValid); + } + + [Fact] + public void Generate_WithBase32Secret_ReturnsTotpCode() { + // Arrange + var timestep = TotpGenerator.GetCurrentTimeStepNumber(); + + // Act + var totpCode = TotpGenerator.Generate(Base32Secret, timestep); + + // Assert + Assert.False(string.IsNullOrEmpty(totpCode)); + Assert.Equal(6, totpCode.Length); + } + + [Fact] + public void GetCurrentTimeStepNumber_ReturnsNonNegativeValue() { + // Act + var timestep = TotpGenerator.GetCurrentTimeStepNumber(); + + // Assert + Assert.True(timestep >= 0); + } + } +} diff --git a/src/MaksIT.Core/MaksIT.Core.csproj b/src/MaksIT.Core/MaksIT.Core.csproj index 0f86869..2768852 100644 --- a/src/MaksIT.Core/MaksIT.Core.csproj +++ b/src/MaksIT.Core/MaksIT.Core.csproj @@ -8,7 +8,7 @@ MaksIT.Core - 1.0.5 + 1.0.6 Maksym Sadovnychyy MAKS-IT MaksIT.Core diff --git a/src/MaksIT.Core/Security/Base32Encoder.cs b/src/MaksIT.Core/Security/Base32Encoder.cs new file mode 100644 index 0000000..86a6603 --- /dev/null +++ b/src/MaksIT.Core/Security/Base32Encoder.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace MaksIT.Core.Security; + +public static class Base32Encoder { + private static readonly char[] Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".ToCharArray(); + private const string PaddingChar = "="; + + public static string Encode(byte[] data) { + if (data == null || data.Length == 0) { + throw new ArgumentNullException(nameof(data)); + } + + var result = new StringBuilder(); + int buffer = data[0]; + int next = 1; + int bitsLeft = 8; + while (bitsLeft > 0 || next < data.Length) { + if (bitsLeft < 5) { + if (next < data.Length) { + buffer <<= 8; + buffer |= (data[next++] & 0xFF); + bitsLeft += 8; + } + else { + int pad = 5 - bitsLeft; + buffer <<= pad; + bitsLeft += pad; + } + } + + int index = (buffer >> (bitsLeft - 5)) & 0x1F; + bitsLeft -= 5; + result.Append(Base32Alphabet[index]); + } + + // Padding for a complete block + int padding = result.Length % 8; + if (padding > 0) { + for (int i = padding; i < 8; i++) { + result.Append(PaddingChar); + } + } + + return result.ToString(); + } + + public static byte[] Decode(string base32) { + if (string.IsNullOrEmpty(base32)) { + throw new ArgumentNullException(nameof(base32)); + } + + base32 = base32.TrimEnd(PaddingChar.ToCharArray()); + int byteCount = base32.Length * 5 / 8; + byte[] result = new byte[byteCount]; + + int buffer = 0; + int bitsLeft = 0; + int index = 0; + + foreach (char c in base32) { + int charValue = CharToValue(c); + if (charValue < 0) { + throw new FormatException("Invalid base32 character."); + } + + buffer <<= 5; + buffer |= charValue & 0x1F; + bitsLeft += 5; + + if (bitsLeft >= 8) { + result[index++] = (byte)(buffer >> (bitsLeft - 8)); + bitsLeft -= 8; + } + } + + return result; + } + + private static int CharToValue(char c) { + if (c >= 'A' && c <= 'Z') { + return c - 'A'; + } + + if (c >= '2' && c <= '7') { + return c - '2' + 26; + } + + return -1; // Invalid character + } +} + diff --git a/src/MaksIT.Core/Security/TtopGenerator.cs b/src/MaksIT.Core/Security/TtopGenerator.cs new file mode 100644 index 0000000..7a8fea9 --- /dev/null +++ b/src/MaksIT.Core/Security/TtopGenerator.cs @@ -0,0 +1,63 @@ +using System.Security.Cryptography; + + +namespace MaksIT.Core.Security; + +public static class TotpGenerator { + private const int Timestep = 30; // Time step in seconds (standard is 30 seconds) + private const int TotpDigits = 6; // Standard TOTP length is 6 digits + + public static bool Validate(string totpCode, string base32Secret, int timeTolerance = 1) { + // Convert the Base32 encoded secret to a byte array + byte[] secretBytes = Base32Encoder.Decode(base32Secret); + + // Get current timestamp + long timeStepWindow = GetCurrentTimeStepNumber(); + + // Validate the TOTP code against the valid time windows (current and around it) + for (int i = -timeTolerance; i <= timeTolerance; i++) { + var generatedTotp = Generate(secretBytes, timeStepWindow + i); + if (generatedTotp == totpCode) { + return true; + } + } + + return false; + } + + public static string Generate(string base32Secret, long timestep) { + // Convert the Base32 encoded secret to a byte array + byte[] secretBytes = Base32Encoder.Decode(base32Secret); + return Generate(secretBytes, timestep); + } + + private static string Generate(byte[] secretBytes, long timestep) { + // Convert the time step to byte array (8-byte big-endian) + byte[] timestepBytes = BitConverter.GetBytes(timestep); + if (BitConverter.IsLittleEndian) { + Array.Reverse(timestepBytes); + } + + // Generate HMAC-SHA1 hash based on the secret and the time step + using (var hmac = new HMACSHA1(secretBytes)) { + byte[] hash = hmac.ComputeHash(timestepBytes); + + // Extract a 4-byte dynamic binary code from the hash + int offset = hash[hash.Length - 1] & 0x0F; + int binaryCode = (hash[offset] & 0x7F) << 24 + | (hash[offset + 1] & 0xFF) << 16 + | (hash[offset + 2] & 0xFF) << 8 + | (hash[offset + 3] & 0xFF); + + // Reduce to the desired number of digits + int totp = binaryCode % (int)Math.Pow(10, TotpDigits); + return totp.ToString(new string('0', TotpDigits)); // Ensure leading zeroes + } + } + + public static long GetCurrentTimeStepNumber() { + var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + return unixTimestamp / Timestep; + } +} +