1
0
mirror of https://github.com/XFox111/SimpleOTP.git synced 2026-07-02 19:52:42 +03:00

3 Commits

Author SHA1 Message Date
xfox111 4c63be6389 Base32 encoder fix (#19)
- Fixed Base32 encoder #6, added test
- Updated NuGet packages
2021-07-09 00:52:00 +03:00
xfox111 a06022ba1e Version 1.2.1 (#18)
- Fixed generated secret length
- Fixed URI encoding issues in `OTPConfiguration.GetUri()`
2021-06-13 19:11:10 +03:00
xfox111 91668e6179 Update SECURITY.md 2021-06-06 23:05:54 +03:00
9 changed files with 88 additions and 74 deletions
+8 -7
View File
@@ -1,14 +1,15 @@
# Security Policy
## Supported Versions
Security patches are delivered with latest library update. If security vulnerabilities affect any on older version they are withdrawn from public access
| .NET Version | Supported |
| ----------------- | ------------------ |
| 5.0+ | :white_check_mark: |
| Core 3.1+ | :white_check_mark: |
| Standard 2.1+ | :white_check_mark: |
| Any older version | :x: |
### Supported .NET versions
| .NET Version | Supported |
| ------------ | ------------------ |
| 5.x.x | :white_check_mark: |
| < 5.0 | :x: |
### End of support
After release of new version of framework previous comes out of support in 3 months
## Reporting a Vulnerability
If you found any vulnerability, please tell us on opensource@xfox111.net. You'll get all updates on a reported issue, unless you stated it in the message
+22 -7
View File
@@ -6,6 +6,7 @@
// ------------------------------------------------------------
using System;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SimpleOTP.Helpers;
@@ -19,19 +20,33 @@ namespace SimpleOTP.Test.Helpers
public class Base32UnitTest
{
/// <summary>
/// Test overall work of the encoder.
/// Test encoder with byte array.
/// </summary>
[TestMethod("Overall Base32 encoder test")]
[TestMethod("Byte array Base32 encoder test")]
public void EncoderTest()
{
// byte[] bytes = new byte[new Random().Next(128, 161)]; // FIXME: See SimpleOTP.Helpers.Base32Encoder.Encode()
byte[] bytes = new byte[160];
byte[] bytes = new byte[new Random().Next(16, 20)];
new Random().NextBytes(bytes);
string str = Base32Encoder.Encode(bytes);
bytes = Base32Encoder.Decode(str);
string result = Base32Encoder.Encode(bytes);
Assert.AreEqual(str, result);
byte[] result = Base32Encoder.Decode(str);
Assert.AreEqual(bytes.Length, result.Length);
for (int i = 0; i < bytes.Length; i++)
Assert.AreEqual(bytes[i], result[i]);
}
/// <summary>
/// Test encoder with string content.
/// </summary>
[TestMethod("String Base32 encoder test")]
public void EncoderStringTest()
{
string testStr = "Hello, World!";
string encodedStr = Base32Encoder.Encode(Encoding.UTF8.GetBytes(testStr));
byte[] resultBytes = Base32Encoder.Decode(encodedStr);
string result = Encoding.UTF8.GetString(resultBytes);
Assert.AreEqual(testStr, result);
}
}
}
@@ -1,4 +1,4 @@
// ------------------------------------------------------------
// ------------------------------------------------------------
// Copyright ©2021 Eugene Fox. All rights reserved.
// Code by Eugene Fox (aka XFox)
//
@@ -29,7 +29,7 @@ namespace SimpleOTP.Test.Models
var testId = config.Id;
System.Diagnostics.Debug.WriteLine(testId);
Uri uri = config.GetUri();
Assert.AreEqual($"otpauth://totp/FoxDev+Studio:eugene@xfox111.net?secret={config.Secret}&issuer=FoxDev+Studio", uri.AbsoluteUri);
Assert.AreEqual($"otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret={config.Secret}&issuer=FoxDev%20Studio", uri.AbsoluteUri);
}
/// <summary>
@@ -39,9 +39,9 @@ namespace SimpleOTP.Test.Models
public void TestFullLinkGenerator()
{
OTPConfiguration config = OTPConfiguration.GetConfiguration("otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev%20Studio%20Issuer&algorithm=SHA512&digits=8&period=10");
Assert.AreEqual($"otpauth://totp/FoxDev+Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev+Studio+Issuer&algorithm=SHA512&digits=8&period=10", config.GetUri().AbsoluteUri);
Assert.AreEqual($"otpauth://totp/FoxDev%20Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev%20Studio%20Issuer&algorithm=SHA512&digits=8&period=10", config.GetUri().AbsoluteUri);
config.Type = Enums.OTPType.HOTP;
Assert.AreEqual($"otpauth://hotp/FoxDev+Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev+Studio+Issuer&algorithm=SHA512&digits=8&counter=0", config.GetUri().AbsoluteUri);
Assert.AreEqual($"otpauth://hotp/FoxDev%20Studio:eugene@xfox111.net?secret=ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH&issuer=FoxDev%20Studio%20Issuer&algorithm=SHA512&digits=8&counter=0", config.GetUri().AbsoluteUri);
}
/// <summary>
@@ -64,28 +64,27 @@ namespace SimpleOTP.Test.Models
OTPConfiguration config = OTPConfiguration.GetConfiguration("ESQVTYRM2CWZC3NX24GRRWIAUUWVHWQH", "FoxDev Studio", "eugene@xfox111.net");
string imageStr = await config.GetQrImage();
Assert.AreEqual(
"data:image/png;base64,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", imageStr);
"data:image/png;base64,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", imageStr);
}
}
}
+2 -2
View File
@@ -24,8 +24,8 @@
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.4" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.4" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.5" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.5" />
<PackageReference Include="coverlet.collector" Version="3.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+22 -23
View File
@@ -5,7 +5,8 @@
// Licensed under MIT license (https://opensource.org/licenses/MIT)
// ------------------------------------------------------------
using System.Collections.Generic;
using System;
using System.Linq;
namespace SimpleOTP.Helpers
{
@@ -14,6 +15,7 @@ namespace SimpleOTP.Helpers
/// </summary>
internal static class Base32Encoder
{
// Standard RFC 4648 Base32 alphabet
private const string AllowedCharacters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
/// <summary>
@@ -23,16 +25,20 @@ namespace SimpleOTP.Helpers
/// <returns>Base32 string.</returns>
internal static string Encode(byte[] data)
{
// FIXME: Encoder works correctly only with 160-bit keys
string binary = string.Empty;
foreach (byte b in data)
binary += Convert.ToString(b, 2).PadLeft(8, '0'); // Getting binary sequence to split into 5 digits
int numberOfBlocks = (binary.Length / 5) + Math.Clamp(binary.Length % 5, 0, 1);
string[] sequence = Enumerable.Range(0, numberOfBlocks)
.Select(i => binary.Substring(i * 5, Math.Min(5, binary.Length - (i * 5))).PadRight(5, '0'))
.ToArray(); // Splitting sequence on groups of 5
string output = string.Empty;
for (int bitIndex = 0; bitIndex < data.Length * 8; bitIndex += 5)
{
int dualbyte = data[bitIndex / 8] << 8;
if ((bitIndex / 8) + 1 < data.Length)
dualbyte |= data[(bitIndex / 8) + 1];
dualbyte = 0x1f & (dualbyte >> (16 - (bitIndex % 8) - 5));
output += AllowedCharacters[dualbyte];
}
foreach (string str in sequence)
output += AllowedCharacters[Convert.ToInt32(str, 2)];
output = output.PadRight(output.Length + (output.Length % 8), '=');
return output;
}
@@ -44,21 +50,14 @@ namespace SimpleOTP.Helpers
/// <returns>Initial byte array.</returns>
internal static byte[] Decode(string base32str)
{
List<byte> output = new ();
char[] bytes = base32str.ToCharArray();
for (var bitIndex = 0; bitIndex < base32str.Length * 5; bitIndex += 8)
{
var dualbyte = AllowedCharacters.IndexOf(bytes[bitIndex / 5]) << 10;
if ((bitIndex / 5) + 1 < bytes.Length)
dualbyte |= AllowedCharacters.IndexOf(bytes[(bitIndex / 5) + 1]) << 5;
if ((bitIndex / 5) + 2 < bytes.Length)
dualbyte |= AllowedCharacters.IndexOf(bytes[(bitIndex / 5) + 2]);
base32str = base32str.Replace("=", string.Empty); // Removing padding
dualbyte = 0xff & (dualbyte >> (15 - (bitIndex % 5) - 8));
output.Add((byte)dualbyte);
}
string[] quintets = base32str.Select(i => Convert.ToString(AllowedCharacters.IndexOf(i), 2).PadLeft(5, '0')).ToArray(); // Getting quintets
string binary = string.Join(null, quintets);
return output.ToArray();
byte[] output = Enumerable.Range(0, binary.Length / 8).Select(i => Convert.ToByte(binary.Substring(i * 8, 8), 2)).ToArray();
return output;
}
}
}
+2 -2
View File
@@ -20,14 +20,14 @@ namespace SimpleOTP.Helpers
/// <param name="length">Length of the key in bits<br/>
/// It should belong to [128-160] bit span<br/>
/// Default is: 160 bits.</param>
/// <remarks>CURRENTLY THIS GENERATOR WORKS CORRECTLY ONLY WITH 160-BIT LENGTHS. Set <paramref name="length"/> at your own risk.</remarks>
/// <remarks>Number of bits will be rounded down to the nearest number which divides by 8.</remarks>
/// <returns>Base32 encoded alphanumeric string with length form 16 to 20 characters.</returns>
public static string GenerateSecret(int length = 160)
{
if (length > 160 || length < 128)
throw new ArgumentOutOfRangeException(nameof(length), "Invalid key length. It should belong to [128-160] bits span");
byte[] key = new byte[length];
byte[] key = new byte[length / 8];
new Random().NextBytes(key);
return Base32Encoder.Encode(key);
+1 -1
View File
@@ -48,4 +48,4 @@ namespace SimpleOTP.Models
public string GetCode(string formatter = "000000") =>
Code.ToString(formatter);
}
}
}
+3 -3
View File
@@ -177,10 +177,10 @@ namespace SimpleOTP.Models
/// <returns>Valid OTP AUTH URI.</returns>
public Uri GetUri()
{
string path = $"otpauth://{Type}/{HttpUtility.UrlEncode(IssuerLabel)}";
string path = $"otpauth://{Type}/{IssuerLabel}";
if (!string.IsNullOrWhiteSpace(AccountName))
path += $":{AccountName}";
path += $"?secret={Secret}&issuer={HttpUtility.UrlEncode(Issuer)}";
path += $"?secret={Secret}&issuer={Issuer}";
if (Algorithm != Algorithm.SHA1)
path += $"&algorithm={Algorithm}";
if (Digits != 6)
@@ -225,4 +225,4 @@ namespace SimpleOTP.Models
return imageString;
}
}
}
}
+3 -3
View File
@@ -12,7 +12,7 @@
<PropertyGroup>
<PackageId>SimpleOTP</PackageId>
<AssemblyName>SimpleOTP</AssemblyName>
<Version>1.2.0</Version>
<Version>1.2.2</Version>
<Description>.NET library for TOTP/HOTP implementation on server (ASP.NET) or client (Xamarin) side</Description>
<Authors>Eugene Fox</Authors>
<Company>FoxDev Studio</Company>
@@ -22,8 +22,8 @@
<RepositoryUrl>https://github.com/XFox111/SimpleOTP</RepositoryUrl>
<NeutralLanguage>en-US</NeutralLanguage>
<PackageTags>otp;totp;dotnet;hotp;authenticator;2fa;mfa;security;oath</PackageTags>
<PackageReleaseNotes>- Expanded support to .NET Standard 2.1 and .NET Core 3.1
- Fixed some documentation typos (https://github.com/XFox111/SimpleOTP/issues/14)</PackageReleaseNotes>
<PackageReleaseNotes>- Fixed Base32 encoder
- Updated NuGet dependency packages</PackageReleaseNotes>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">