(feature): ttop generator and base32 encoder
This commit is contained in:
parent
f80aa1dd95
commit
885f3d80a4
53
src/MaksIT.Core.Tests/Security/Base32EncoderTests.cs
Normal file
53
src/MaksIT.Core.Tests/Security/Base32EncoderTests.cs
Normal file
@ -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<FormatException>(() => 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);
|
||||
}
|
||||
}
|
||||
69
src/MaksIT.Core.Tests/Security/TtopGeneratorTests.cs
Normal file
69
src/MaksIT.Core.Tests/Security/TtopGeneratorTests.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>MaksIT.Core</PackageId>
|
||||
<Version>1.0.5</Version>
|
||||
<Version>1.0.6</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Core</Product>
|
||||
|
||||
95
src/MaksIT.Core/Security/Base32Encoder.cs
Normal file
95
src/MaksIT.Core/Security/Base32Encoder.cs
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
63
src/MaksIT.Core/Security/TtopGenerator.cs
Normal file
63
src/MaksIT.Core/Security/TtopGenerator.cs
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user