1
0
mirror of https://github.com/XFox111/SimpleOTP.git synced 2026-04-22 08:00:45 +03:00

Major 2.0 (#20)

* New 2.0 version + DependencyInjection library

* Updated docs and repo settings (devcontainers, vscode, github, etc.)

* Added tests

* Fixed bugs

* Minor test project refactoring

* Updated projects
- Added symbol packages
- Updated package versions to pre-release

* Updated SECURITY.md

* Added GitHub Actions workflows
This commit is contained in:
2024-09-26 03:20:30 +03:00
committed by GitHub
parent 42f968171b
commit 1b989e7b35
87 changed files with 4076 additions and 2532 deletions
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SimpleOTP.Tests")]
@@ -0,0 +1,33 @@
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Provides methods for generating and validating One-Time Passwords.
/// </summary>
public interface IOtpService
{
/// <summary>
/// Creates an OTP URI for specified user and secret.
/// </summary>
/// <param name="username">The username of the user.</param>
/// <param name="secret">The secret to use.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns>The generated URI.</returns>
public Uri CreateUri(string username, OtpSecret secret, long counter = 0);
/// <summary>
/// Creates an OTP code for specified user and secret.
/// </summary>
/// <param name="secret">The secret to use.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
public OtpCode GenerateCode(OtpSecret secret, long counter = 0);
/// <summary>
/// Validates an OTP code for specified user and secret.
/// </summary>
/// <param name="code">The code to validate.</param>
/// <param name="secret">The secret to use.</param>
/// <param name="resyncValue">The resync value. Shows how much the code is ahead or behind the current counter value.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns><c>true</c> if the code is valid; otherwise, <c>false</c>.</returns>
public bool ValidateCode(OtpCode code, OtpSecret secret, out int resyncValue, long counter = 0);
}
@@ -0,0 +1,57 @@
using System.Collections.Specialized;
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Provides options for the One-Time Password service.
/// </summary>
public class OtpOptions
{
/// <summary>
/// The name of the issuer.
/// </summary>
public required string Issuer { get; set; }
/// <summary>
/// The issuer domain.
/// </summary>
/// <remarks>
/// <b>IMPORTANT:</b> Using this property will imply adherence to the Apple specification.
/// </remarks>
public string? IssuerDomain { get; set; }
/// <summary>
/// The algorithm to use.
/// </summary>
public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1;
/// <summary>
/// The number of digits in the OTP code.
/// </summary>
public int Digits { get; set; } = 6;
/// <summary>
/// The number of seconds between each OTP code.
/// </summary>
public int Period { get; set; } = 30;
/// <summary>
/// The type of One-Time Password to generate.
/// </summary>
public OtpType Type { get; set; } = OtpType.Totp;
/// <summary>
/// The format of OTP URIs.
/// </summary>
public OtpUriFormat UriFormat { get; set; } = OtpUriFormat.Google | OtpUriFormat.Minimal;
/// <summary>
/// The tolerance span for the OTP codes validation.
/// </summary>
public ToleranceSpan ToleranceSpan { get; set; } = ToleranceSpan.Default;
/// <summary>
/// Custom properties to place in OTP URIs.
/// </summary>
public NameValueCollection CustomProperties { get; } = [];
}
@@ -0,0 +1,95 @@
using System.Collections.Specialized;
using SimpleOTP.Fluent;
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Provides methods for generating and validating One-Time Passwords.
/// </summary>
/// <param name="configuration">The configuration for the One-Time Password service.</param>
internal class OtpService(OtpOptions configuration) : IOtpService
{
private readonly string _issuerName = configuration.Issuer;
private readonly string? _issuerDomain = configuration.IssuerDomain;
private readonly OtpAlgorithm _algorithm = configuration.Algorithm;
private readonly OtpType _type = configuration.Type;
private readonly OtpUriFormat _format = configuration.UriFormat |
(string.IsNullOrWhiteSpace(configuration.IssuerDomain) ? 0 : OtpUriFormat.Apple);
private readonly int _digits = configuration.Digits;
private readonly int _period = configuration.Period;
private readonly NameValueCollection _customProperties = configuration.CustomProperties;
private readonly ToleranceSpan _tolerance = configuration.ToleranceSpan;
/// <summary>
/// Creates an OTP URI for specified user and secret.
/// </summary>
/// <param name="username">The username of the user.</param>
/// <param name="secret">The secret to use.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns>The generated URI.</returns>
public Uri CreateUri(string username, OtpSecret secret, long counter = 0)
{
OtpConfig config = new(username)
{
Algorithm = _algorithm,
Type = _type,
Issuer = _issuerName,
Digits = _digits,
Period = _period,
Secret = secret,
Counter = counter
};
if (!string.IsNullOrWhiteSpace(_issuerDomain))
config.WithAppleIssuer(_issuerName, _issuerDomain);
config.CustomProperties.Add(_customProperties);
return config.ToUri(_format);
}
/// <summary>
/// Creates an OTP code for specified user and secret.
/// </summary>
/// <param name="secret">The secret to use.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns>The generated code.</returns>
/// <exception cref="NotSupportedException">The service was not configured properly. Check the "Authenticator:Type" configuration.</exception>
public OtpCode GenerateCode(OtpSecret secret, long counter = 0)
{
using OtpSecret secretClone = OtpSecret.CreateCopy(secret);
Otp generator = _type switch
{
OtpType.Hotp => new Hotp(secret, counter, _algorithm, _digits),
OtpType.Totp => new Totp(secret, _period, _algorithm, _digits),
_ => throw new NotSupportedException("The service was not configured properly. Check the \"Authenticator:Type\" configuration.")
};
return generator.Generate();
}
/// <summary>
/// Validates an OTP code for specified user and secret.
/// </summary>
/// <param name="code">The code to validate.</param>
/// <param name="secret">The secret to use.</param>
/// <param name="resyncValue">The resync value. Shows how much the code is ahead or behind the current counter value.</param>
/// <param name="counter">(only for HOTP) The counter to use.</param>
/// <returns><c>true</c> if the code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="NotSupportedException">The service was not configured properly. Check the "Authenticator:Type" configuration.</exception>
public bool ValidateCode(OtpCode code, OtpSecret secret, out int resyncValue, long counter = 0)
{
using OtpSecret secretClone = OtpSecret.CreateCopy(secret);
Otp generator = _type switch
{
OtpType.Hotp => new Hotp(secret, counter, _algorithm, _digits),
OtpType.Totp => new Totp(secret, _period, _algorithm, _digits),
_ => throw new NotSupportedException("The service was not configured properly. Check the \"Authenticator:Type\" configuration.")
};
return generator.Validate(code, _tolerance, out resyncValue);
}
}
@@ -0,0 +1,76 @@
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Configuration for the One-Time Password service.
/// </summary>
public class OtpServiceConfig
{
/// <summary>
/// The name of the issuer.
/// </summary>
public string Issuer { get; set; } = null!;
/// <summary>
/// The issuer domain.
/// </summary>
/// <remarks>
/// <b>IMPORTANT:</b> Using this property will imply adherence to the Apple specification.
/// </remarks>
public string? IssuerDomain { get; set; }
/// <summary>
/// The algorithm to use.
/// </summary>
public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1;
/// <summary>
/// The number of digits in the OTP code.
/// </summary>
public int Digits { get; set; } = 6;
/// <summary>
/// The number of seconds between each OTP code.
/// </summary>
public int Period { get; set; } = 30;
/// <summary>
/// The type of One-Time Password to generate.
/// </summary>
public OtpType Type { get; set; } = OtpType.Totp;
/// <summary>
/// The format of OTP URIs.
/// </summary>
public OtpUriFormat UriFormat { get; set; } = OtpUriFormat.Google;
/// <summary>
/// Whether to use minimal URI formatting (only required, or altered properties are included), or full URI formatting.
/// </summary>
public bool MinimalUri { get; set; } = true;
/// <summary>
/// The tolerance span for the OTP codes validation.
/// </summary>
public ToleranceSpanConfig ToleranceSpan { get; set; } = new();
/// <summary>
/// Custom properties to place in OTP URIs.
/// </summary>
public Dictionary<string, string> CustomProperties { get; } = [];
}
/// <summary>
/// Configuration for the tolerance span.
/// </summary>
public class ToleranceSpanConfig
{
/// <summary>
/// The number of periods/counter values behind the current value.
/// </summary>
public int Behind { get; set; } = ToleranceSpan.Default.Behind;
/// <summary>
/// The number of periods/counter values ahead of the current value.
/// </summary>
public int Ahead { get; set; } = ToleranceSpan.Default.Ahead;
}
@@ -0,0 +1,96 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace SimpleOTP.DependencyInjection;
/// <summary>
/// Extension methods for the One-Time Password service.
/// </summary>
public static class OtpServiceExtensions
{
/// <summary>
/// Adds the One-Time Password service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="issuerName">The issuer/application/service name.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddAuthenticator(this IServiceCollection services, string issuerName) =>
AddAuthenticator(services, issuerName);
/// <summary>
/// Adds the One-Time Password service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="issuerName">The issuer/application/service name.</param>
/// <param name="configure">The configuration for the One-Time Password service.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddAuthenticator(this IServiceCollection services, string issuerName, Action<OtpOptions>? configure = null)
{
OtpOptions options = new()
{
Issuer = issuerName
};
configure?.Invoke(options);
services.AddTransient<IOtpService, OtpService>(_ => new OtpService(options));
return services;
}
/// <summary>
/// Adds the One-Time Password service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration for the One-Time Password service.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddAuthenticator(this IServiceCollection services, IConfiguration configuration) =>
AddAuthenticator(services, configuration);
/// <summary>
/// Adds the One-Time Password service to the service collection.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration for the One-Time Password service.</param>
/// <param name="configure">The configuration for the One-Time Password service.</param>
/// <returns>A reference to this instance after the operation has completed.</returns>
public static IServiceCollection AddAuthenticator(this IServiceCollection services, IConfiguration configuration, Action<OtpOptions>? configure = null)
{
OtpOptions? options = GetOptionsFromConfiguration(configuration);
if (options is null)
return services;
configure?.Invoke(options);
services.AddTransient<IOtpService, OtpService>(_ => new OtpService(options));
return services;
}
private static OtpOptions? GetOptionsFromConfiguration(IConfiguration configuration)
{
OtpServiceConfig config = new();
IConfigurationSection configSection = configuration.GetSection("Authenticator");
if (!configSection.Exists() || string.IsNullOrWhiteSpace(configuration["Authenticator:Issuer"]))
return null;
configSection.Bind(config);
OtpOptions options = new()
{
Issuer = config.Issuer,
Algorithm = config.Algorithm,
Type = config.Type,
Digits = config.Digits,
Period = config.Period,
IssuerDomain = config.IssuerDomain,
ToleranceSpan = (config.ToleranceSpan.Behind, config.ToleranceSpan.Ahead),
UriFormat = config.UriFormat
};
options.UriFormat |= config.MinimalUri ? OtpUriFormat.Minimal : OtpUriFormat.Full;
foreach (KeyValuePair<string, string> pair in config.CustomProperties)
options.CustomProperties.Add(pair.Key, pair.Value);
return options;
}
}
@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<PackageId>EugeneFox.SimpleOTP.DependencyInjection</PackageId>
<Version>8.0.0.0-rc1</Version>
<Authors>Eugene Fox</Authors>
<Copyright>Copyright © Eugene Fox 2024</Copyright>
<NeutralLanguage>en-US</NeutralLanguage>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<PropertyGroup>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/XFox111/SimpleOTP.git</RepositoryUrl>
<PackageProjectUrl>https://github.com/XFox111/SimpleOTP</PackageProjectUrl>
</PropertyGroup>
<PropertyGroup>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup>
<PackageTags>
otp;totp;hotp;authenticator;authentication;one-time;2fa;mfa;security;otpauth;services;dependency-injection;di</PackageTags>
<Description>
Dependency Injection implementation for SimpleOTP library. Allows to use SimpleOTP as DI
service in your application.
</Description>
<PackageReleaseNotes>
Initial release. See README.md for details.
</PackageReleaseNotes>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\assets\icon.png">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SimpleOTP\SimpleOTP.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.*" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="8.0.*" />
</ItemGroup>
</Project>
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("SimpleOTP.Tests")]
@@ -0,0 +1,18 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleOTP.Converters;
/// <summary>
/// Provides a JSON converter for <see cref="OtpAlgorithm"/>.
/// </summary>
public class OtpAlgorithmJsonConverter : JsonConverter<OtpAlgorithm>
{
/// <inheritdoc/>
public override OtpAlgorithm Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(reader.GetString() ?? OtpAlgorithm.SHA1);
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, OtpAlgorithm value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString());
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleOTP.Converters;
/// <summary>
/// Provides a JSON converter for <see cref="OtpCode"/>.
/// </summary>
public class OtpCodeJsonConverter : JsonConverter<OtpCode>
{
/// <inheritdoc/>
public override OtpCode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string? code = null;
DateTimeOffset? expirationTime = null;
while (reader.Read())
if (reader.TokenType == JsonTokenType.PropertyName)
{
string propertyName = reader.GetString()!;
reader.Read();
if (reader.TokenType != JsonTokenType.String)
continue;
if (propertyName.Equals("Code", StringComparison.OrdinalIgnoreCase))
code = reader.GetString();
if (propertyName.Equals("Expiring", StringComparison.OrdinalIgnoreCase) &&
reader.TryGetDateTimeOffset(out DateTimeOffset expiring))
expirationTime = expiring;
}
if (code is null)
throw new JsonException("Missing required property 'Code'.");
return new OtpCode(code, expirationTime);
}
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, OtpCode value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteString("Code", value.ToString());
if (value.ExpirationTime.HasValue)
writer.WriteString("Expiring", value.ExpirationTime.Value);
else
writer.WriteNull("Expiring");
writer.WriteEndObject();
}
}
@@ -0,0 +1,18 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleOTP.Converters;
/// <summary>
/// Provides a JSON converter for <see cref="OtpConfig"/>.
/// </summary>
public class OtpConfigJsonConverter : JsonConverter<OtpConfig>
{
/// <inheritdoc/>
public override OtpConfig? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
OtpConfig.ParseUri(reader.GetString()!);
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, OtpConfig value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToUri().AbsoluteUri);
}
@@ -0,0 +1,18 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SimpleOTP.Converters;
/// <summary>
/// Provides a JSON converter for <see cref="OtpSecret"/>.
/// </summary>
public class OtpSecretJsonConverter : JsonConverter<OtpSecret>
{
/// <inheritdoc/>
public override OtpSecret Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
new(reader.GetString()!);
/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, OtpSecret value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString());
}
@@ -0,0 +1,120 @@
namespace SimpleOTP.Encoding;
/// <summary>
/// Provides methods for encoding and decoding data using the RFC 4648 Base32 standard alphabet.
/// </summary>
public class Base32Encoder : IEncoder
{
/// <summary>
/// Gets the singleton instance of the <see cref="Base32Encoder"/> class.
/// </summary>
public static Base32Encoder Instance { get; } = new();
/// <inheritdoc />
public virtual string Scheme => "base32";
/// <summary>
/// Converts a byte array to a Base32 string representation.
/// </summary>
/// <param name="bytes">The byte array to convert.</param>
/// <returns>The Base32 string representation of the byte array.</returns>
/// <exception cref="ArgumentNullException">Thrown when parameter is null.</exception>
public string EncodeBytes(byte[] bytes)
{
ArgumentNullException.ThrowIfNull(bytes);
if (bytes.Length < 1)
return string.Empty;
char[] outArray = new char[(int)Math.Ceiling(bytes.Length * 8 / 5d)];
int bitIndex = 0;
int buffer = 0;
int filledChars = 0;
for (int i = 0; i < bytes.Length; i++)
{
if (bitIndex >= 5)
{
outArray[filledChars++] = ValueToChar(buffer >> (bitIndex - 5) & 0x1F);
bitIndex -= 5;
}
outArray[filledChars++] = ValueToChar(((buffer << (5 - bitIndex)) & 0x1F) | bytes[i] >> (3 + bitIndex));
buffer = bytes[i];
bitIndex = 3 + bitIndex;
}
// Adding trailing bits
if (bitIndex > 0)
outArray[filledChars] = ValueToChar(buffer << (5 - bitIndex) & 0x1F);
return new string(outArray);
}
/// <summary>
/// Converts a Base32 encoded string to a byte array.
/// </summary>
/// <param name="inArray">The Base32 encoded string to convert.</param>
/// <remarks>Trailing bits are ignored (e.g. AAAR will be treated as AAAQ - 0x00 0x01).</remarks>
/// <returns>The byte array representation of the Base32 encoded string.</returns>
/// <exception cref="ArgumentNullException">Thrown when parameter is null.</exception>
/// <exception cref="ArgumentException">Thrown when <paramref name="inArray"/> is empty, whitespace, or contains invalid characters.</exception>
public byte[] GetBytes(string inArray)
{
ArgumentException.ThrowIfNullOrWhiteSpace(inArray);
inArray = inArray.TrimEnd('=');
int buffer = 0x00;
int bitIndex = 0;
int filledBytes = 0;
byte[] outArray = new byte[inArray.Length * 5 / 8];
for (int i = 0; i < inArray.Length; i++)
{
int value = CharToValue(inArray[i]);
buffer = (buffer << 5) | value;
bitIndex += 5;
if (bitIndex >= 8)
{
// We have enough bits to fill a byte, flushing it
outArray[filledBytes++] = (byte)((buffer >> (bitIndex - 8)) & 0xFF);
bitIndex -= 8;
buffer &= 0xFF; // Trimming value to 1 byte to prevent overflow for long strings
}
}
return outArray;
}
/// <summary>
/// Converts a Base32 character to its numeric value.
/// </summary>
/// <param name="c">The Base32 character to convert.</param>
/// <returns>The numeric value of the Base32 character.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="c"/> is not a valid Base32 character.</exception>
protected virtual int CharToValue(char c) =>
(int)c switch
{
> 0x31 and < 0x38 => c - 0x18,
> 0x40 and < 0x5B => c - 0x41,
> 0x60 and < 0x7B => c - 0x61,
_ => throw new ArgumentException("Character is not a Base32 character.", nameof(c)),
};
/// <summary>
/// Converts a numeric value to its Base32 character.
/// </summary>
/// <param name="value">The numeric value to convert.</param>
/// <returns>The Base32 character corresponding to the numeric value.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="value"/> is not a valid Base32 value.</exception>
protected virtual char ValueToChar(int value) =>
value switch
{
< 26 => (char)(value + 0x41),
< 32 => (char)(value + 0x18),
_ => throw new ArgumentException("Byte is not a Base32 byte.", nameof(value)),
};
}
+26
View File
@@ -0,0 +1,26 @@
namespace SimpleOTP.Encoding;
/// <summary>
/// Provides methods for encoding and decoding data using the RFC 4648 Base32 "Extended Hex" alphabet.
/// </summary>
public interface IEncoder
{
/// <summary>
/// Gets the encoding scheme used by the encoder (e.g. <c>base32</c> or <c>base32hex</c>).
/// </summary>
public string Scheme { get; }
/// <summary>
/// Converts a byte array to a Base32 string representation.
/// </summary>
/// <param name="data">The byte array to convert.</param>
/// <returns>The Base32 string representation of the byte array.</returns>
public string EncodeBytes(byte[] data);
/// <summary>
/// Converts a Base32 encoded string to a byte array.
/// </summary>
/// <param name="data">The Base32 encoded string to convert.</param>
/// <returns>The byte array representation of the Base32 encoded string.</returns>
public byte[] GetBytes(string data);
}
+40
View File
@@ -0,0 +1,40 @@
namespace SimpleOTP.Fluent;
/// <summary>
/// Class used to streamline OTP code generation on client devices.
/// </summary>
public static class OtpBuilder
{
/// <summary>
/// Use TOTP generator with optional counter period.
/// </summary>
/// <param name="period">Period in seconds.</param>
/// <returns><see cref="Otp"/> instance.</returns>
public static Otp UseTotp(int period = 30) =>
new Totp(OtpSecret.CreateNew(), period);
/// <summary>
/// Use HOTP generator with optional counter value.
/// </summary>
/// <param name="counter">Counter value.</param>
/// <returns><see cref="Otp"/> instance.</returns>
public static Otp UseHotp(long counter = 0) =>
new Hotp(OtpSecret.CreateNew(), counter);
/// <summary>
/// Creates <see cref="Otp"/> instance from <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="config"><see cref="OtpConfig"/> object.</param>
/// <returns><see cref="Otp"/> instance.</returns>
public static Otp FromConfig(OtpConfig config)
{
Otp generator = config.Type == OtpType.Totp ?
new Totp(config.Secret, config.Period) :
new Hotp(config.Secret, config.Counter);
generator.Algorithm = config.Algorithm;
generator.Digits = config.Digits;
return generator;
}
}
@@ -0,0 +1,51 @@
namespace SimpleOTP.Fluent;
/// <summary>
/// Class used to streamline OTP code configuration on client devices.
/// </summary>
public static class OtpConfigBuilder
{
/// <summary>
/// Use TOTP configuration with optional counter period.
/// </summary>
/// <param name="accountName">Account name.</param>
/// <param name="period">Period in seconds.</param>
/// <returns><see cref="OtpConfig"/> instance.</returns>
public static OtpConfig UseTotp(string accountName, int period = 30) =>
new(accountName)
{
Type = OtpType.Totp,
Period = period
};
/// <summary>
/// Use HOTP configuration with optional counter.
/// </summary>
/// <param name="accountName">Account name.</param>
/// <param name="counter">Counter value.</param>
/// <returns><see cref="OtpConfig"/> instance.</returns>
public static OtpConfig UseHotp(string accountName, long counter = 0) =>
new(accountName)
{
Type = OtpType.Hotp,
Counter = counter
};
/// <summary>
/// Use TOTP which satisfies Apple's specification requirements.
/// </summary>
/// <param name="accountName">Account name.</param>
/// <param name="issuerName">Issuer/application/service display name.</param>
/// <param name="issuerDomain">Issuer/application/service domain name.</param>
/// <returns><see cref="OtpConfig"/> instance.</returns>
public static OtpConfig UseApple(string accountName, string issuerName, string issuerDomain) =>
new(accountName)
{
Type = OtpType.Totp,
Secret = OtpSecret.CreateNew(20),
Digits = 6,
IssuerLabel = issuerName,
Issuer = issuerDomain,
Label = accountName
};
}
@@ -0,0 +1,118 @@
namespace SimpleOTP.Fluent;
/// <summary>
/// Provides fluent API for configuring <see cref="OtpConfig"/> objects.
/// </summary>
public static class OtpConfigFluentExtensions
{
/// <summary>
/// Sets the <see cref="OtpConfig.Label"/> property.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="label">The label of the OTP config.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithLabel(this OtpConfig config, string label)
{
config.Label = label;
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Issuer"/> property.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="issuer">The issuer of the OTP config.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithIssuer(this OtpConfig config, string? issuer)
{
config.Issuer = issuer;
return config;
}
/// <summary>
/// Sets the issuer info, according to Apple specification.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="displayName">The display name of the issuer.</param>
/// <param name="domain">The domain name of the issuer.</param>
public static OtpConfig WithAppleIssuer(this OtpConfig config, string displayName, string domain)
{
config.IssuerLabel = displayName;
config.Issuer = domain;
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Secret"/> property with a new secret.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="bytesLength">The length of the secret in bytes.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithNewSecret(this OtpConfig config, int bytesLength)
{
config.Secret = OtpSecret.CreateNew(bytesLength);
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Secret"/> property with specified secret.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="secret">The secret to use.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithSecret(this OtpConfig config, OtpSecret secret)
{
config.Secret = secret;
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Algorithm"/> property.
/// </summary>
/// <remarks>Not recommended for use, since most implementations do not support custom values.</remarks>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="algorithm">The algorithm to use.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithAlgorithm(this OtpConfig config, OtpAlgorithm algorithm)
{
config.Algorithm = algorithm;
return config;
}
/// <summary>
/// Sets the <see cref="OtpConfig.Digits"/> property.
/// </summary>
/// <remarks>Not recommended for use, since most implementations do not support custom values.</remarks>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="digits">The number of digits to use.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig WithDigits(this OtpConfig config, int digits)
{
config.Digits = digits;
return config;
}
/// <summary>
/// Adds a custom vendor-specific property to the <see cref="OtpConfig"/>.
/// </summary>
/// <remarks>If set, reserved keys
/// <c>issuer, digits, counter, secret, period and algorithm</c>
/// will be removed from the <see cref="OtpConfig.CustomProperties"/> upon it's serialization to URI.</remarks>
/// <param name="config">The <see cref="OtpConfig"/> object to configure.</param>
/// <param name="key">The key of the property.</param>
/// <param name="value">The value of the property.</param>
/// <returns>The configured <see cref="OtpConfig"/> object.</returns>
public static OtpConfig AddCustomProperty(this OtpConfig config, string key, string value)
{
config.CustomProperties.Add(key, value);
return config;
}
/// <summary>
/// Creates a new <see cref="Otp"/> object from the provided <see cref="OtpConfig"/>
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to use.</param>
/// <returns>A new <see cref="Otp"/> object.</returns>
public static Otp CreateGenerator(this OtpConfig config) =>
OtpBuilder.FromConfig(config);
}
@@ -0,0 +1,55 @@
namespace SimpleOTP.Fluent;
/// <summary>
/// Provides fluent API for configuring <see cref="Otp"/> objects.
/// </summary>
public static class OtpFluentExtensions
{
/// <summary>
/// Creates a new <see cref="Otp"/> object from the provided <see cref="OtpConfig"/>
/// </summary>
/// <param name="generator">The <see cref="Otp"/> object to configure.</param>
/// <param name="bytesLength">The length of the secret in bytes.</param>
/// <returns>The configured <see cref="Otp"/> object.</returns>
public static Otp WithNewSecret(this Otp generator, int bytesLength)
{
generator.Secret = OtpSecret.CreateNew(bytesLength);
return generator;
}
/// <summary>
/// Creates a new <see cref="Otp"/> object from the provided <see cref="OtpSecret"/>
/// </summary>
/// <param name="generator">The <see cref="Otp"/> object to configure.</param>
/// <param name="secret">The <see cref="OtpSecret"/> to use.</param>
/// <returns>The configured <see cref="Otp"/> object.</returns>
public static Otp WithSecret(this Otp generator, OtpSecret secret)
{
generator.Secret = secret;
return generator;
}
/// <summary>
/// Sets the <see cref="Otp.Digits"/> property.
/// </summary>
/// <param name="generator">The <see cref="Otp"/> object to configure.</param>
/// <param name="digits">The number of digits to use in OTP codes.</param>
/// <returns>The configured <see cref="Otp"/> object.</returns>
public static Otp WithDigits(this Otp generator, int digits)
{
generator.Digits = digits;
return generator;
}
/// <summary>
/// Sets the <see cref="Otp.Algorithm"/> property.
/// </summary>
/// <param name="generator">The <see cref="Otp"/> object to configure.</param>
/// <param name="algorithm">The algorithm to use.</param>
/// <returns>The configured <see cref="Otp"/> object.</returns>
public static Otp WithAlgorithm(this Otp generator, OtpAlgorithm algorithm)
{
generator.Algorithm = algorithm;
return generator;
}
}
@@ -0,0 +1,59 @@
using System.Security.Cryptography;
namespace SimpleOTP;
/// <summary>
/// Provides methods for registering and retrieving <see cref="KeyedHashAlgorithm"/> providers.
/// </summary>
public static class HashAlgorithmProviders
{
private static readonly Dictionary<OtpAlgorithm, Func<KeyedHashAlgorithm>> _registeredProviders = new()
{
{ OtpAlgorithm.SHA1, () => new HMACSHA1() },
{ OtpAlgorithm.SHA256, () => new HMACSHA256() },
{ OtpAlgorithm.SHA512, () => new HMACSHA512() },
{ OtpAlgorithm.MD5, () => new HMACMD5() }
};
/// <summary>
/// Registers a new <see cref="KeyedHashAlgorithm"/> provider.
/// </summary>
/// <param name="algorithm">The algorithm to register.</param>
public static void AddProvider<TAlgorithm>(OtpAlgorithm algorithm)
where TAlgorithm : KeyedHashAlgorithm, new() =>
_registeredProviders[algorithm] = () => new TAlgorithm();
/// <summary>
/// Retrieves a <see cref="KeyedHashAlgorithm"/> provider.
/// </summary>
/// <param name="algorithm">The algorithm to retrieve.</param>
/// <returns>The <see cref="KeyedHashAlgorithm"/> provider, or <c>null</c> if not found.</returns>
public static KeyedHashAlgorithm? GetProvider(OtpAlgorithm algorithm)
{
if (_registeredProviders.TryGetValue(algorithm, out var provider))
return provider();
return null;
}
/// <summary>
/// Removes a <see cref="KeyedHashAlgorithm"/> provider.
/// </summary>
/// <param name="algorithm">The algorithm to remove.</param>
public static void RemoveProvider(OtpAlgorithm algorithm) =>
_registeredProviders.Remove(algorithm);
/// <summary>
/// Determines whether a <see cref="KeyedHashAlgorithm"/> provider is registered.
/// </summary>
/// <param name="algorithm">The algorithm to check.</param>
/// <returns><c>true</c> if the <see cref="KeyedHashAlgorithm"/> provider is registered; otherwise, <c>false</c>.</returns>
public static bool IsRegistered(OtpAlgorithm algorithm) =>
_registeredProviders.ContainsKey(algorithm);
/// <summary>
/// Removes all registered <see cref="KeyedHashAlgorithm"/> providers.
/// </summary>
/// <remarks>This method also clears default providers. Use with caution.</remarks>
public static void ClearProviders() => _registeredProviders.Clear();
}
+60
View File
@@ -0,0 +1,60 @@
namespace SimpleOTP;
/// <summary>
/// Represents a HOTP (HMAC-based One-Time Password) generator.
/// </summary>
public class Hotp : Otp
{
/// <summary>
/// Gets or sets the counter value used for generating OTP codes.
/// </summary>
public long Counter { get; set; } = 0;
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
public Hotp(OtpSecret secret) : base(secret) { }
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="counter">The counter value used for generating OTP codes.</param>
public Hotp(OtpSecret secret, long counter) : base(secret) =>
Counter = counter;
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="counter">The counter value used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Hotp(OtpSecret secret, long counter, int digits) : base(secret, digits) =>
Counter = counter;
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="counter">The counter value used for generating OTP codes.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
public Hotp(OtpSecret secret, long counter, OtpAlgorithm algorithm) : base(secret, algorithm) =>
Counter = counter;
/// <summary>
/// Initializes a new instance of the <see cref="Hotp"/> class
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="counter">The counter value used for generating OTP codes.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Hotp(OtpSecret secret, long counter, OtpAlgorithm algorithm, int digits) : base(secret, algorithm, digits) =>
Counter = counter;
/// <summary>
/// Gets the current counter value.
/// </summary>
/// <returns>The current counter value.</returns>
protected override long GetCounter() => Counter;
}
+212
View File
@@ -0,0 +1,212 @@
using System.Security.Cryptography;
namespace SimpleOTP;
// TODO: Add tests
/// <summary>
/// Represents an abstract class for generating and validating One-Time Passwords (OTP).
/// </summary>
public abstract class Otp
{
#region Properties
/// <summary>
/// Gets or sets the secret key used for generating OTPs.
/// </summary>
public OtpSecret Secret { get; set; }
/// <summary>
/// Gets or sets the algorithm used for generating OTP codes.
/// </summary>
public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1;
/// <summary>
/// Gets or sets the number of digits in the OTP code.
/// </summary>
/// <value>Default: 6. Recommended: 6-8.</value>
public int Digits { get; set; } = 6;
#endregion
#region Constructors
/// <summary>
/// Initializes a new instance of the <see cref="Otp"/> class.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Otp(OtpSecret secret, OtpAlgorithm algorithm, int digits) =>
(Secret, Algorithm, Digits) = (secret, algorithm, digits);
/// <summary>
/// Initializes a new instance of the <see cref="Otp"/> class.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
public Otp(OtpSecret secret, OtpAlgorithm algorithm) : this(secret, algorithm, 6) { }
/// <summary>
/// Initializes a new instance of the <see cref="Otp"/> class.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Otp(OtpSecret secret, int digits) : this(secret, OtpAlgorithm.SHA1, digits) { }
/// <summary>
/// Initializes a new instance of the <see cref="Otp"/> class.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
public Otp(OtpSecret secret) : this(secret, OtpAlgorithm.SHA1, 6) { }
#endregion
#region Methods
// Generate
/// <summary>
/// Generates an OTP code.
/// </summary>
/// <returns>The generated OTP code.</returns>
public OtpCode Generate() =>
Generate(GetCounter());
/// <summary>
/// Generates an OTP code for the specified counter value.
/// </summary>
/// <param name="counter">The counter value to generate the OTP code for.</param>
/// <returns>The generated OTP code.</returns>
public virtual OtpCode Generate(long counter) =>
new(Compute(counter), Digits);
// Validate
/// <summary>
/// Validates an OTP code.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code) =>
Validate(code, (1, 1));
/// <summary>
/// Validates an OTP code with tolerance.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <param name="tolerance">The tolerance span for code validation.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code, ToleranceSpan tolerance) =>
Validate(code, tolerance, out _);
/// <summary>
/// Validates an OTP code with tolerance and returns the resynchronization value.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <param name="tolerance">The tolerance span for code validation.</param>
/// <param name="resyncValue">The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code, ToleranceSpan tolerance, out int resyncValue) =>
Validate(code, tolerance, GetCounter(), out resyncValue);
/// <summary>
/// Validates an OTP code with tolerance and base counter value, and returns the resynchronization value.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <param name="tolerance">The tolerance span for code validation.</param>
/// <param name="baseCounter">The base counter value.</param>
/// <param name="resyncValue">The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code, ToleranceSpan tolerance, long baseCounter, out int resyncValue)
{
resyncValue = 0;
using KeyedHashAlgorithm? hashAlgorithm = HashAlgorithmProviders.GetProvider(Algorithm) ??
throw new InvalidOperationException($"Implementation for the \"{Algorithm}\" algorithm was not found.");
for (int i = -tolerance.Behind; i <= tolerance.Ahead; i++)
if (code == Compute(baseCounter + i, hashAlgorithm).ToString($"D{Digits}"))
{
resyncValue = i;
return true;
}
return false;
}
/// <summary>
/// Gets the current counter value.
/// </summary>
/// <returns>The current counter value.</returns>
protected abstract long GetCounter();
/// <summary>
/// Computes the OTP code for the specified counter value.
/// </summary>
/// <param name="counter">The counter value to compute the OTP code for.</param>
/// <returns>The OTP code for the specified counter value.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
protected int Compute(long counter)
{
using KeyedHashAlgorithm? hashAlgorithm = HashAlgorithmProviders.GetProvider(Algorithm) ??
throw new InvalidOperationException($"Implementation for the \"{Algorithm}\" algorithm was not found.");
return Compute(counter, hashAlgorithm);
}
/// <summary>
/// Computes the OTP code for the specified counter value using provided hash algorithm.
/// </summary>
/// <param name="counter">The counter value to compute the OTP code for.</param>
/// <param name="hashAlgorithm">The hash algorithm to use for computing the OTP code.</param>
/// <remarks>You need to dispose of the <paramref name="hashAlgorithm"/> object yourself when you are done using it.</remarks>
/// <returns>The OTP code for the specified counter value.</returns>
protected virtual int Compute(long counter, KeyedHashAlgorithm hashAlgorithm)
{
byte[] counterBytes = BitConverter.GetBytes(counter);
// "The HOTP values generated by the HOTP generator are treated as big endian."
// https://datatracker.ietf.org/doc/html/rfc4226#section-5.2
if (BitConverter.IsLittleEndian)
Array.Reverse(counterBytes);
hashAlgorithm.Key = Secret;
byte[] hash = hashAlgorithm.ComputeHash(counterBytes);
// Converting hash to n-digits value
// See RFC4226 Section 5.4 for more details
// https://datatracker.ietf.org/doc/html/rfc4226#section-5.4
int offset = hash[^1] & 0x0F;
int value =
(hash[offset + 0] & 0x7F) << 24 | // Result value should be a 31-bit integer, hence the 0x7F (0111 1111)
(hash[offset + 1] & 0xFF) << 16 |
(hash[offset + 2] & 0xFF) << 8 |
(hash[offset + 3] & 0xFF) << 0;
return value % (int)Math.Pow(10, Digits);
}
#endregion
}
+151
View File
@@ -0,0 +1,151 @@
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Schema;
using System.Xml.Serialization;
using SimpleOTP.Converters;
namespace SimpleOTP;
/// <summary>
/// Represents the hashing algorithm used for One-Time Passwords.
/// </summary>
[Serializable]
[JsonConverter(typeof(OtpAlgorithmJsonConverter))]
public readonly partial struct OtpAlgorithm : IEquatable<OtpAlgorithm>, IEquatable<string>, IXmlSerializable
{
/// <summary>
/// The HMAC-SHA1 hashing algorithm.
/// </summary>
public static OtpAlgorithm SHA1 { get; } = new("SHA1");
/// <summary>
/// The HMAC-SHA256 hashing algorithm.
/// </summary>
public static OtpAlgorithm SHA256 { get; } = new("SHA256");
/// <summary>
/// The HMAC-SHA512 hashing algorithm.
/// </summary>
public static OtpAlgorithm SHA512 { get; } = new("SHA512");
/// <summary>
/// The HMAC-MD5 hashing algorithm.
/// </summary>
/// <remarks>
/// This is not a standard algorithm, but it is defined by IIJ specification and recognized by default.<br />
/// <a href="https://www1.auth.iij.jp/smartkey/en/uri_v1.html">Internet Initiative Japan. URI format</a>
/// </remarks>
public static OtpAlgorithm MD5 { get; } = new("MD5");
private readonly string _value;
/// <summary>
/// Initializes a new instance of the <see cref="OtpAlgorithm"/> struct.
/// </summary>
/// <param name="value">The algorithm to use.</param>
/// <exception cref="ArgumentException">Thrown if <paramref name="value"/> is empty or whitespace.</exception>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="value"/> is <see langword="null"/>.</exception>
public OtpAlgorithm(string value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(value);
if (StandardAlgorithmsRegex().IsMatch(value))
_value = StandardAlgorithmsRegex().Match(value).Value.ToUpperInvariant();
else
_value = value.ToUpperInvariant();
}
/// <inheritdoc/>
public bool Equals(OtpAlgorithm other) =>
_value == other._value;
/// <inheritdoc/>
public bool Equals(string? other)
{
if (string.IsNullOrWhiteSpace(other))
return _value is null;
if (_value is null)
return false;
return Equals(new OtpAlgorithm(other));
}
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is OtpAlgorithm algorithm && Equals(algorithm);
/// <summary>
/// Determines whether the specified <see cref="OtpAlgorithm"/> is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512).
/// </summary>
/// <returns><see langword="true"/> if the specified <see cref="OtpAlgorithm"/> is standard; otherwise, <see langword="false"/>.</returns>
public bool IsStandard() =>
IsStandard(_value);
/// <inheritdoc/>
public override int GetHashCode() => _value.GetHashCode();
/// <summary>
/// Returns the string representation of the <see cref="OtpAlgorithm"/> struct.
/// </summary>
/// <returns>The string representation of the <see cref="OtpAlgorithm"/> struct.</returns>
public override string ToString() => _value;
/// <inheritdoc/>
public XmlSchema? GetSchema() => null;
/// <inheritdoc/>
public void ReadXml(XmlReader reader)
{
reader.MoveToContent();
if (reader.NodeType != XmlNodeType.Element)
throw new XmlException("Invalid XML element.");
string algorithm = reader.ReadElementContentAsString();
#pragma warning disable CS9195 // Argument should be passed with the in keyword
Unsafe.AsRef(this) = new OtpAlgorithm(algorithm);
#pragma warning restore CS9195 // Argument should be passed with the in keyword
}
/// <inheritdoc/>
public void WriteXml(XmlWriter writer)
{
writer.WriteAttributeString("standard", IsStandard().ToString().ToLowerInvariant());
writer.WriteString(_value);
}
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static bool operator ==(OtpAlgorithm left, OtpAlgorithm right) => left.Equals(right);
public static bool operator ==(string left, OtpAlgorithm right) => right.Equals(left);
public static bool operator ==(OtpAlgorithm left, string right) => left.Equals(right);
public static bool operator !=(OtpAlgorithm left, OtpAlgorithm right) => !(left == right);
public static bool operator !=(string left, OtpAlgorithm right) => !(left == right);
public static bool operator !=(OtpAlgorithm left, string right) => !(left == right);
public static implicit operator string(OtpAlgorithm algorithm) => algorithm._value;
public static explicit operator OtpAlgorithm(string value) => new(value);
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
/// <summary>
/// Determines whether the specified <see cref="OtpAlgorithm"/> is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512).
/// </summary>
/// <param name="algorithm">The algorithm to check.</param>
/// <returns><see langword="true"/> if the specified <see cref="OtpAlgorithm"/> is standard; otherwise, <see langword="false"/>.</returns>
public static bool IsStandard(string algorithm) =>
StandardAlgorithmsRegex().IsMatch(algorithm);
/// <summary>
/// Determines whether the specified <see cref="OtpAlgorithm"/> is standard HMAC SHA algorithm (SHA-1, SHA-256 or SHA-512).
/// </summary>
/// <param name="algorithm">The algorithm to check.</param>
/// <returns><see langword="true"/> if the specified <see cref="OtpAlgorithm"/> is standard; otherwise, <see langword="false"/>.</returns>
public static bool IsStandard(OtpAlgorithm algorithm) =>
IsStandard(algorithm._value);
[GeneratedRegex(@"(?<=Hmac)?SHA(1|256|512)", RegexOptions.IgnoreCase, "")]
private static partial Regex StandardAlgorithmsRegex();
}
+111
View File
@@ -0,0 +1,111 @@
namespace SimpleOTP;
// THIS IS THE BASE OF A PARTIAL STRUCT
// List of files
// - OtpCode.Base.cs - Base file
// - OtpCode.Static.cs - Static members
// - OtpCode.Serialization.cs - JSON/XML serialization members and attributes
/// <summary>
/// Represents a one-time password (OTP) code.
/// </summary>
public readonly partial struct OtpCode : IEquatable<OtpCode>, IEquatable<string>
{
private readonly int _value;
private readonly int _digits;
/// <summary>
/// Gets a value indicating whether the OTP code can expire (<c>true</c> for TOTP, <c>false</c> for HOTP).
/// </summary>
public readonly bool CanExpire => ExpirationTime is not null;
/// <summary>
/// Gets the expiration time of the OTP code (TOTP only).
/// </summary>
public readonly DateTimeOffset? ExpirationTime { get; }
/// <summary>
/// Initializes a new instance of the <see cref="OtpCode"/> struct with the specified value with no expiration time.
/// </summary>
/// <param name="code">The value of the OTP code.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public OtpCode(int code, int digits) : this(code, digits, null) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpCode"/> struct with the specified value and the expiration time.
/// </summary>
/// <param name="code">The value of the OTP code.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
/// <param name="expirationTime">The expiration time of the OTP code (TOTP only).</param>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public OtpCode(int code, int digits, DateTimeOffset? expirationTime = null)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(code);
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(digits);
_value = code;
_digits = digits;
ExpirationTime = expirationTime;
}
/// <summary>
/// Initializes a new instance of the <see cref="OtpCode"/> struct with the specified value with no expiration time.
/// </summary>
/// <param name="code">The value of the OTP code.</param>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public OtpCode(string code) : this(code, null) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpCode"/> struct with the specified value and the expiration time.
/// </summary>
/// <param name="code">The value of the OTP code.</param>
/// <param name="expirationTime">The expiration time of the OTP code (TOTP only).</param>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public OtpCode(string code, DateTimeOffset? expirationTime = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(code);
if (!int.TryParse(code, out var value))
throw new ArgumentException($"'{code}' is not a valid numeric code.", nameof(code));
_value = value;
_digits = code.Length;
ExpirationTime = expirationTime;
}
/// <summary>
/// Returns a string representation of the OTP code.
/// </summary>
/// <returns>A string representation of the OTP code.</returns>
public override readonly string ToString() =>
_value.ToString($"D{_digits}");
/// <summary>
/// Returns a string representation of the OTP code.
/// </summary>
/// <param name="format">The format to use.</param>
/// <returns>The string representation of the OTP code.</returns>
public readonly string ToString(string? format) =>
_value.ToString(format);
/// <inheritdoc/>
public bool Equals(OtpCode other) =>
ToString() == other.ToString();
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is OtpCode code && Equals(code);
/// <inheritdoc/>
public bool Equals(string? other) =>
ToString() == other;
/// <inheritdoc/>
public override int GetHashCode() =>
_value.GetHashCode();
}
@@ -0,0 +1,54 @@
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Xml;
using System.Xml.Schema;
using System.Xml.Serialization;
using SimpleOTP.Converters;
namespace SimpleOTP;
// THIS IS THE SECTION OF A PARTIAL STRUCT
// Description: Section of OtpCode struct that holds JSON/XML serialization members and attributes
// Base file: OtpCode.Base.cs
[Serializable]
[JsonConverter(typeof(OtpCodeJsonConverter))]
public readonly partial struct OtpCode : IXmlSerializable
{
/// <inheritdoc/>
public XmlSchema? GetSchema() => null;
/// <inheritdoc/>
public void ReadXml(XmlReader reader)
{
reader.MoveToContent();
if (reader.NodeType != XmlNodeType.Element)
throw new XmlException("Invalid XML element.");
DateTimeOffset? expirationTime = null;
if (reader.HasAttributes && reader.MoveToAttribute("expiring"))
expirationTime = DateTimeOffset.ParseExact(reader.ReadContentAsString(), "O", null);
reader.MoveToContent();
if (reader.NodeType != XmlNodeType.Element)
throw new XmlException("Invalid XML content.");
string code = reader.ReadElementContentAsString();
#pragma warning disable CS9195 // Argument should be passed with the in keyword
Unsafe.AsRef(this) = new OtpCode(code, expirationTime);
#pragma warning restore CS9195 // Argument should be passed with the in keyword
}
/// <inheritdoc/>
public void WriteXml(XmlWriter writer)
{
if (ExpirationTime.HasValue)
writer.WriteAttributeString("expiring", ExpirationTime.Value.ToString("O"));
writer.WriteString(ToString());
}
}
@@ -0,0 +1,51 @@
namespace SimpleOTP;
// THIS IS THE SECTION OF A PARTIAL STRUCT
// Description: Section of OtpCode struct that holds static members
// Base file: OtpCode.Base.cs
public readonly partial struct OtpCode
{
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static implicit operator string(OtpCode code) => code.ToString();
public static implicit operator OtpCode(string code) => new(code);
public static bool operator ==(OtpCode left, OtpCode right) => left.Equals(right);
public static bool operator ==(string left, OtpCode right) => right.Equals(left);
public static bool operator ==(OtpCode left, string right) => left.Equals(right);
public static bool operator !=(OtpCode left, OtpCode right) => !(left == right);
public static bool operator !=(string left, OtpCode right) => !(left == right);
public static bool operator !=(OtpCode left, string right) => !(left == right);
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
/// <summary>
/// Parses the specified <see cref="string"/> into an <see cref="OtpCode"/> object.
/// </summary>
/// <param name="code">The string to parse.</param>
/// <returns>An <see cref="OtpCode"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="code"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="code"/> is not a valid numeric code.</exception>
public static OtpCode Parse(string code) =>
new(code);
/// <summary>
/// Tries to parse the specified <see cref="string"/> into an <see cref="OtpCode"/> object.
/// </summary>
/// <param name="code">The string to parse.</param>
/// <param name="result">The parsed <see cref="OtpCode"/> object.</param>
/// <returns><see langword="true"/> if <paramref name="code"/> was parsed successfully; otherwise, <see langword="false"/>.</returns>
public static bool TryParse(string code, out OtpCode result)
{
try
{
result = new(code);
return true;
}
catch
{
result = default;
return false;
}
}
}
@@ -0,0 +1,130 @@
using System.Collections.Specialized;
namespace SimpleOTP;
// THIS IS THE BASE OF A PARTIAL CLASS
// List of files
// - OtpConfig.Base.cs - Base file
// - OtpConfig.Constructors.cs - Instance constructors
// - OtpConfig.Methods.cs - Instance methods and serialization
// - OtpConfig.Static.cs - Static members
/// <summary>
/// Represents the configuration for a One-Time Password (OTP).
/// </summary>
public partial record class OtpConfig
{
/// <summary>
/// Gets or sets the type of the OTP.
/// </summary>
/// <value>Default is: <see cref="OtpType.Totp"/></value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.1">Internet-Draft</a>.<br />
/// <b>IMPORTANT:</b> Some authenticators do not support <see cref="OtpType.Hotp"/>.
/// </remarks>
public OtpType Type { get; set; } = OtpType.Totp;
/// <summary>
/// Gets or sets the issuer label prefix of the OTP.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>Not recommended for use in most cases.</item>
/// <item>Most authenticators do not support this prefix and mess with the <see cref="Label"/> string.</item>
/// <item>Required if you intend to use <see cref="OtpUriFormat.Apple"/>. Use this prefix to set the issuer display name.</item>
/// </list>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.2">Internet-Draft</a>.
/// </remarks>
public string? IssuerLabel { get; set; }
/// <summary>
/// Gets or sets the label of the OTP.
/// </summary>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.2">Internet-Draft</a>.
/// </remarks>
public string Label { get; set; }
/// <summary>
/// Gets or sets the secret of the OTP.
/// </summary>
/// <value>Default: 160-bit key. Minimal recommended: 128 bits</value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.1">Internet-Draft</a>
/// </remarks>
public OtpSecret Secret { get; set; } = OtpSecret.CreateNew();
/// <summary>
/// Gets or sets the hashing algorithm of the OTP.
/// </summary>
/// <value>Default: <see cref="OtpAlgorithm.SHA1"/></value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.2">Internet-Draft</a><br />
/// <b>IMPORTANT:</b> Some authenticators do not support algorithms other than <see cref="OtpAlgorithm.SHA1"/>.
/// </remarks>
public OtpAlgorithm Algorithm { get; set; } = OtpAlgorithm.SHA1;
/// <summary>
/// Gets or sets the issuer of the OTP. Optional.
/// </summary>
/// <remarks>
/// <list type="bullet">
/// <item>Use this property instead of <see cref="IssuerLabel"/>.</item>
/// <item>Required if you intend to use <see cref="OtpUriFormat.Apple"/>. Use this property to set the issuer domain name.</item>
/// </list>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.6">Internet-Draft</a>
/// </remarks>
public string? Issuer { get; set; }
/// <summary>
/// Gets or sets the number of digits of the OTP codes.
/// </summary>
/// <value>Default: 6. Recommended: 6 or 8</value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.3">Internet-Draft</a><br />
/// <b>IMPORTANT:</b> Some authenticators do not support digits other than 6.
/// </remarks>
public int Digits { get; set; } = 6;
/// <summary>
/// Gets or sets the counter of the OTP. Required for <see cref="OtpType.Hotp"/>. Ignored for <see cref="OtpType.Totp"/>.
/// </summary>
/// <value>Default: 0</value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.4">Internet-Draft</a><br />
/// <b>IMPORTANT:</b> Some authenticators do not support <see cref="OtpType.Hotp"/>.
/// </remarks>
public long Counter { get; set; } = 0;
/// <summary>
/// Gets or sets the period of the OTP in seconds. Optional for <see cref="OtpType.Totp"/>. Ignored for <see cref="OtpType.Hotp"/>.
/// </summary>
/// <value>Default: 30</value>
/// <remarks>
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.5">Internet-Draft</a><br />
/// <b>IMPORTANT:</b> Some authenticators support only periods of 30 seconds.
/// </remarks>
public int Period { get; set; } = 30;
/// <summary>
/// Gets the custom vendor-specified properties of the current OTP configuration.
/// </summary>
/// <remarks>
/// If set, reserved keys
/// <c>issuer, digits, counter, secret, period and algorithm</c>
/// will be removed from the <see cref="CustomProperties"/> upon it's serialization to URI.<br />
/// <a href="https://www.ietf.org/archive/id/draft-linuxgemini-otpauth-uri-00.html#section-3.3.7">Internet-Draft</a>
/// </remarks>
public NameValueCollection CustomProperties { get; } = [];
// Reserved keys, which are to be removed for CustomProperties
private static readonly string[] _reservedKeys =
[
nameof(Issuer),
nameof(Digits),
nameof(Counter),
nameof(Secret),
nameof(Period),
nameof(Algorithm)
];
}
@@ -0,0 +1,104 @@
using System.Collections.Specialized;
using System.Net;
using System.Web;
using SimpleOTP.Encoding;
namespace SimpleOTP;
// THIS IS THE SECTION OF A PARTIAL CLASS
// Description: Section of OtpConfig struct that holds instance constructors
// Base file: OtpConfig.Base.cs
public partial record class OtpConfig
{
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="label">The label of the OTP config.</param>
public OtpConfig(string label) =>
Label = label;
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="label">The label of the OTP config.</param>
/// <param name="issuer">The issuer of the OTP config.</param>
public OtpConfig(string label, string issuer) =>
(Label, Issuer) = (label, issuer);
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="label">The label of the OTP config.</param>
/// <param name="secret">The secret of the OTP config.</param>
public OtpConfig(string label, OtpSecret secret) =>
(Label, Secret) = (label, secret);
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="label">The label of the OTP config.</param>
/// <param name="issuer">The issuer of the OTP config.</param>
/// <param name="secret">The secret of the OTP config.</param>
public OtpConfig(string label, string issuer, OtpSecret secret) =>
(Label, Issuer, Secret) = (label, issuer, secret);
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="uri">The URI of the OTP config.</param>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public OtpConfig(Uri uri) : this(uri, OtpSecret.DefaultEncoder) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="uri">The URI of the OTP config.</param>
/// <param name="encoder">The encoder used to decode the secret.</param>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public OtpConfig(Uri uri, IEncoder encoder)
{
if (uri.Scheme != "otpauth" && uri.Scheme != "apple-otpauth")
throw new ArgumentException("Invalid URI scheme. Expected 'otpauth' or 'apple-otpauth'.");
Type = uri.Host.ToLowerInvariant() switch
{
"totp" => OtpType.Totp,
"hotp" => OtpType.Hotp,
_ => throw new ArgumentException("Invalid OTP type. Expected 'totp' or 'hotp'.")
};
NameValueCollection query = HttpUtility.ParseQueryString(uri.Query);
Secret = OtpSecret.Parse(query[nameof(Secret)] ?? throw new ArgumentException("Secret is required."), encoder);
string label = WebUtility.UrlDecode(uri.Segments[^1]);
string[] labelParts = label.Split(':', 2, StringSplitOptions.RemoveEmptyEntries);
Label = labelParts.Last();
if (labelParts.Length > 1)
IssuerLabel = labelParts[0];
if (long.TryParse(query[nameof(Counter)], out long counter) || Type != OtpType.Hotp)
Counter = counter;
else
throw new ArgumentException("Counter is required for HOTP algorithm.");
if (query.Get(nameof(Issuer)) is string issuer)
Issuer = issuer;
if (query.Get(nameof(Algorithm)) is string algorithm && !string.IsNullOrWhiteSpace(algorithm))
Algorithm = (OtpAlgorithm)algorithm;
if (int.TryParse(query[nameof(Period)], out int period))
Period = period;
if (int.TryParse(query[nameof(Digits)], out int digits))
Digits = digits;
foreach (string key in _reservedKeys)
query.Remove(key);
CustomProperties.Add(query);
}
}
@@ -0,0 +1,112 @@
using System.Collections.Specialized;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Web;
using System.Xml;
using System.Xml.Schema;
using System.Xml.Serialization;
using SimpleOTP.Converters;
namespace SimpleOTP;
// THIS IS THE SECTION OF A PARTIAL CLASS
// Description: Section of OtpConfig struct that holds instance methods
// Base file: OtpConfig.Base.cs
[Serializable]
[JsonConverter(typeof(OtpConfigJsonConverter))]
public partial record class OtpConfig : IXmlSerializable
{
/// <summary>
/// Converts the current <see cref="OtpConfig"/> object to a <see cref="Uri"/> object.
/// </summary>
/// <remarks>
/// Uses minimal Google specified formatting (<see cref="OtpUriFormat.Minimal"/> | <see cref="OtpUriFormat.Google"/>).
/// </remarks>
/// <returns>A <see cref="Uri"/> object representing the current <see cref="OtpConfig"/> object.</returns>
public Uri ToUri() =>
ToUri(OtpUriFormat.Minimal | OtpUriFormat.Google);
/// <summary>
/// Converts the current <see cref="OtpConfig"/> object to a <see cref="Uri"/> object.
/// </summary>
/// <param name="format">A bitwise combination of the enumeration values that specifies the format of the URI.</param>
/// <returns>A <see cref="Uri"/> object representing the current <see cref="OtpConfig"/> object.</returns>
public Uri ToUri(OtpUriFormat format)
{
string scheme = format.HasFlag(OtpUriFormat.Apple) ? "apple-otpauth" : "otpauth";
string label = HttpUtility.UrlEncode(Label).Replace("+", "%20");
if (!string.IsNullOrWhiteSpace(IssuerLabel))
label = $"{HttpUtility.UrlEncode(IssuerLabel).Replace("+", "%20")}:{label}";
UriBuilder uri = new(scheme, Type.ToString().ToLowerInvariant(), -1, label);
NameValueCollection query = HttpUtility.ParseQueryString(uri.Query);
query["secret"] = Secret.ToString();
if (Type == OtpType.Hotp)
query["counter"] = Counter.ToString();
if (Issuer is not null)
query["issuer"] = Issuer;
if (format.HasFlag(OtpUriFormat.Full) || !Algorithm.Equals(OtpAlgorithm.SHA1))
{
if (format.HasFlag(OtpUriFormat.IBM) && Algorithm.IsStandard())
query["algorithm"] = "Hmac" + Algorithm;
else
query["algorithm"] = Algorithm;
}
if (format.HasFlag(OtpUriFormat.Full) || Digits != 6)
query["digits"] = Digits.ToString();
if (format.HasFlag(OtpUriFormat.Full) || Period != 30)
query["period"] = Period.ToString();
foreach (string key in _reservedKeys)
CustomProperties.Remove(key);
query.Add(CustomProperties);
uri.Query = query.ToString();
return uri.Uri;
}
/// <summary>
/// Returns if the specified <see cref="OtpConfig"/> object is valid.
/// </summary>
/// <param name="error">The error message returned if the <see cref="OtpConfig"/> object is invalid.</param>
/// <param name="format">The <see cref="OtpUriFormat"/> to use for validation.</param>
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
public bool IsValid([NotNullWhen(false)] out string? error, OtpUriFormat format = OtpUriFormat.Google) =>
Validate(this, out error, format);
/// <inheritdoc/>
public override string ToString() =>
ToUri().AbsoluteUri;
/// <inheritdoc/>
public XmlSchema? GetSchema() => null;
/// <inheritdoc/>
public void ReadXml(XmlReader reader)
{
reader.MoveToContent();
if (reader.NodeType != XmlNodeType.Element)
throw new XmlException("Invalid XML element.");
#pragma warning disable CS9193 // Argument should be a variable because it is passed to a 'ref readonly' parameter
Unsafe.AsRef(this) = ParseUri(reader.ReadElementContentAsString());
#pragma warning restore CS9193 // Argument should be a variable because it is passed to a 'ref readonly' parameter
}
/// <inheritdoc/>
public void WriteXml(XmlWriter writer)
{
writer.WriteString(ToUri().AbsoluteUri);
}
}
@@ -0,0 +1,154 @@
using System.Diagnostics.CodeAnalysis;
using SimpleOTP.Encoding;
namespace SimpleOTP;
// THIS IS THE SECTION OF A PARTIAL CLASS
// Description: Section of OtpConfig struct that holds static members
// Base file: OtpConfig.Base.cs
public partial record class OtpConfig
{
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static implicit operator Uri(OtpConfig config) => config.ToUri();
public static implicit operator string(OtpConfig config) => config.ToString();
public static explicit operator OtpConfig(Uri uri) => ParseUri(uri);
public static explicit operator OtpConfig(string uri) => ParseUri(uri);
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
/// <summary>
/// Parses the specified URI into an <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <returns>An <see cref="OtpConfig"/> object parsed from the specified URI.</returns>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public static OtpConfig ParseUri(Uri uri) =>
new(uri);
/// <summary>
/// Initializes a new instance of the <see cref="OtpConfig"/> class.
/// </summary>
/// <param name="uri">The URI of the OTP.</param>
/// <param name="encoder">The encoder used to decode the secret.</param>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public static OtpConfig ParseUri(Uri uri, IEncoder encoder) =>
new(uri, encoder);
/// <summary>
/// Parses the specified URI into an <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <returns>An <see cref="OtpConfig"/> object parsed from the specified URI.</returns>
/// <exception cref="ArgumentException">Provided URI is not valid (missing required values or has invalid required values).</exception>
/// <exception cref="UriFormatException">Provided URI is not valid (missing required values or has invalid required values).</exception>
public static OtpConfig ParseUri(string uri) =>
new(new Uri(uri));
/// <summary>
/// Tries to parse the specified URI into an <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <param name="config">When this method returns, contains the <see cref="OtpConfig"/> object parsed from the specified URI, if the conversion succeeded, or <c>null</c> if the conversion failed.</param>
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
public static bool TryParseUri(Uri uri, [NotNullWhen(true)] out OtpConfig? config)
{
try
{
config = new(uri);
return true;
}
catch
{
config = null;
return false;
}
}
/// <summary>
/// Tries to parse the specified URI into an <see cref="OtpConfig"/> object.
/// </summary>
/// <param name="uri">The URI to parse.</param>
/// <param name="config">When this method returns, contains the <see cref="OtpConfig"/> object parsed from the specified URI, if the conversion succeeded, or <c>null</c> if the conversion failed.</param>
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
public static bool TryParseUri(string uri, [NotNullWhen(true)] out OtpConfig? config) =>
TryParseUri(new Uri(uri), out config);
/// <summary>
/// Returns if the specified <see cref="OtpConfig"/> object is valid.
/// </summary>
/// <param name="config">The <see cref="OtpConfig"/> object to validate.</param>
/// <param name="error">The error message returned if the <see cref="OtpConfig"/> object is invalid.</param>
/// <param name="format">The <see cref="OtpUriFormat"/> to use for validation.</param>
/// <remarks>The <paramref name="format"/> should contain at least one vendor-specific format.</remarks>
/// <returns><c>true</c> if the conversion succeeded; otherwise, <c>false</c>.</returns>
public static bool Validate(OtpConfig config, [NotNullWhen(false)] out string? error, OtpUriFormat format = OtpUriFormat.Google)
{
List<string> errors = [];
// Check label presence
if (string.IsNullOrWhiteSpace(config.Label))
errors.Add($"- '{nameof(config.Label)}' is required and must be a display name for the account.");
if ((format.HasFlag(OtpUriFormat.Apple) || format.HasFlag(OtpUriFormat.IIJ)) && config.Type != OtpType.Totp)
errors.Add($"- '{nameof(config.Type)}' must be '{OtpType.Totp}'.");
// Vendor-specific formats validation
if (format.HasFlag(OtpUriFormat.Apple))
{
if (string.IsNullOrWhiteSpace(config.Issuer) || Uri.CheckHostName(config.Issuer) != UriHostNameType.Dns)
errors.Add($"- '{nameof(config.Issuer)}' is required and must be a valid DNS name.");
if (string.IsNullOrWhiteSpace(config.IssuerLabel))
errors.Add($"- '{nameof(config.IssuerLabel)}' is required and must be a display name for the issuer.");
if (((byte[])config.Secret).Length < 20)
errors.Add($"- '{nameof(config.Secret)}' is required and must be at least 20 bytes long.");
}
// All vendors, except Apple reccommend that
// if issuer label is specified, it should be the same as the issuer
if (!format.HasFlag(OtpUriFormat.Apple) &&
!string.IsNullOrWhiteSpace(config.IssuerLabel) && config.IssuerLabel != config.Issuer)
errors.Add($"- (optional) '{nameof(config.IssuerLabel)}' should be the same as '{nameof(config.Issuer)}'.");
if (format.HasFlag(OtpUriFormat.Yubico))
{
if (config.Type == OtpType.Totp && config.Period is not 15 or 30 or 60)
errors.Add($"- '{nameof(config.Period)}' must be 15, 30 or 60.");
}
// Check for digits value
if (config.Digits is not 6 or 8)
{
// Now it's time for IBM and Yubico to be weird
if (format.HasFlag(OtpUriFormat.IBM) && config.Digits is not 7 and not 9)
errors.Add($"- '{nameof(config.Digits)}' must be 6-9.");
if (format.HasFlag(OtpUriFormat.Yubico) && config.Digits is not 7)
errors.Add($"- '{nameof(config.Digits)}' must be 6-8.");
else
errors.Add($"- '{nameof(config.Digits)}' must be 6 or 8.");
}
// Algorithm validation
if (!config.Algorithm.IsStandard())
{
// IIJ can also have an MD5 algorithm
if (format.HasFlag(OtpUriFormat.IIJ) && config.Algorithm != OtpAlgorithm.MD5)
errors.Add($"- '{nameof(config.Algorithm)}' must be a standard algorithm, defined by IIJ (SHA1/256/512 or MD5).");
else
errors.Add($"- '{nameof(config.Algorithm)}' must be a standard algorithm (SHA1, SHA256 or SHA512).");
}
if (errors.Count > 0)
{
error = string.Join("\n", errors);
return false;
}
error = null;
return true;
}
}
@@ -0,0 +1,109 @@
using System.Numerics;
using System.Security.Cryptography;
using System.Xml.Serialization;
using SimpleOTP.Encoding;
namespace SimpleOTP;
// THIS IS THE BASE OF A PARTIAL CLASS
// List of files
// - OtpSecret.Base.cs - Base file
// - OtpSecret.Static.cs - Static members
// - OtpSecret.Serialization.cs - JSON/XML serialization members and attributes
/// <summary>
/// Represents a one-time password secret.
/// </summary>
public partial class OtpSecret : IEquatable<OtpSecret>, IEquatable<byte[]>, IXmlSerializable, IDisposable
{
private readonly byte[] _secret;
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class with a default length of 20 bytes (160 bits).
/// </summary>
public OtpSecret() : this(20) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class with a random secret of the specified length.
/// </summary>
/// <remarks>
/// 20 bytes (160 bits) is the recommended key length specified by <a href="https://datatracker.ietf.org/doc/html/rfc4226#section-4">RFC 4226</a>.
/// Minimal recommended length is 16 bytes (128 bits).
/// </remarks>
/// <param name="length">The length of the secret in bytes.</param>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="length"/> is less than 1.</exception>
public OtpSecret(int length) =>
_secret = RandomNumberGenerator.GetBytes(length);
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class from a byte array.
/// </summary>
/// <param name="secret">The byte array.</param>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c> or empty.</exception>
public OtpSecret(byte[] secret)
{
if (secret is null || secret.Length < 1)
throw new ArgumentNullException(nameof(secret));
_secret = secret;
}
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class from a Base32-encoded string (RFC 4648 §6).
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="secret"/> is empty or contains invalid characters or only whitespace.</exception>
public OtpSecret(string secret) : this(secret, DefaultEncoder) { }
/// <summary>
/// Initializes a new instance of the <see cref="OtpSecret"/> class from an encoded string.
/// </summary>
/// <param name="secret">The encoded string.</param>
/// <param name="encoder">The encoder.</param>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c> or empty.</exception>
/// <exception cref="ArgumentException"><paramref name="secret"/> is empty or contains invalid characters or only whitespace.</exception>
public OtpSecret(string secret, IEncoder encoder)
{
ArgumentException.ThrowIfNullOrWhiteSpace(secret, nameof(secret));
_secret = encoder.GetBytes(secret);
}
/// <summary>
/// Returns the Base32-encoded string representation of the current <see cref="OtpSecret"/> object.
/// </summary>
/// <returns>The Base32-encoded string representation of the current <see cref="OtpSecret"/> object.</returns>
public override string ToString() =>
DefaultEncoder.EncodeBytes(_secret);
/// <summary>
/// Returns the string representation of the current <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="encoder">The encoder.</param>
/// <returns>The string representation of the current <see cref="OtpSecret"/> object.</returns>
public string ToString(IEncoder encoder) =>
encoder.EncodeBytes(_secret);
/// <inheritdoc/>
public bool Equals(OtpSecret? other) =>
other is not null && _secret.SequenceEqual(other._secret);
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is OtpSecret other && Equals(other);
/// <inheritdoc/>
public bool Equals(byte[]? other) =>
other is not null && _secret.SequenceEqual(other);
/// <inheritdoc/>
public override int GetHashCode() =>
new BigInteger(_secret ?? []).GetHashCode();
/// <inheritdoc/>
public void Dispose()
{
Array.Clear(_secret, 0, _secret.Length);
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,42 @@
using System.Runtime.CompilerServices;
using System.Text.Json.Serialization;
using System.Xml;
using System.Xml.Schema;
using SimpleOTP.Converters;
namespace SimpleOTP;
// THIS IS THE SECTION OF A PARTIAL CLASS
// Description: Section of OtpSecret class that holds JSON/XML serialization members and attributes
// Base file: OtpSecret.Base.cs
[Serializable]
[JsonConverter(typeof(OtpSecretJsonConverter))]
public partial class OtpSecret
{
/// <inheritdoc/>
public XmlSchema? GetSchema() => null;
/// <inheritdoc/>
public void ReadXml(XmlReader reader)
{
reader.MoveToContent();
if (reader.NodeType != XmlNodeType.Element)
throw new XmlException("Invalid XML element.");
byte[] secret = DefaultEncoder.GetBytes(reader.ReadElementContentAsString());
#pragma warning disable CS9193 // Argument should be a variable because it is passed to a 'ref readonly' parameter
Unsafe.AsRef(this) = new OtpSecret(secret);
#pragma warning restore CS9193 // Argument should be a variable because it is passed to a 'ref readonly' parameter
}
/// <inheritdoc/>
public void WriteXml(XmlWriter writer)
{
writer.WriteAttributeString("encoding", DefaultEncoder.Scheme);
writer.WriteAttributeString("length", _secret.Length.ToString());
writer.WriteString(DefaultEncoder.EncodeBytes(_secret));
}
}
@@ -0,0 +1,132 @@
using SimpleOTP.Encoding;
namespace SimpleOTP;
// THIS IS THE SECTION OF A PARTIAL CLASS
// Description: Section of OtpSecret class that holds static members
// Base file: OtpSecret.Base.cs
public partial class OtpSecret
{
/// <summary>
/// Gets or sets the default encoder for parsing/encoding/serializing secrets.
/// </summary>
public static IEncoder DefaultEncoder { get; set; } = Base32Encoder.Instance;
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static implicit operator byte[](OtpSecret secret) => secret._secret;
public static implicit operator string(OtpSecret secret) => secret.ToString();
public static explicit operator OtpSecret(byte[] secret) => new(secret);
public static explicit operator OtpSecret(string secret) => new(secret);
public static bool operator ==(OtpSecret left, OtpSecret right) => left.Equals(right);
public static bool operator ==(OtpSecret left, byte[] right) => left.Equals(right);
public static bool operator ==(OtpSecret left, string right) => left.Equals(right);
public static bool operator ==(byte[] left, OtpSecret right) => right.Equals(left);
public static bool operator ==(string left, OtpSecret right) => right.Equals(left);
public static bool operator !=(OtpSecret left, OtpSecret right) => !(left == right);
public static bool operator !=(OtpSecret left, byte[] right) => !(left == right);
public static bool operator !=(OtpSecret left, string right) => !(left == right);
public static bool operator !=(byte[] left, OtpSecret right) => !(left == right);
public static bool operator !=(string left, OtpSecret right) => !(left == right);
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
/// <summary>
/// Creates a copy of the specified <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="source">The <see cref="OtpSecret"/> object to copy.</param>
/// <returns>A copy of the specified <see cref="OtpSecret"/> object.</returns>
public static OtpSecret CreateCopy(OtpSecret source)
{
byte[] bytes = new byte[source._secret.Length];
Array.Copy(source._secret, bytes, source._secret.Length);
return new(bytes);
}
/// <summary>
/// Creates a new random <see cref="OtpSecret"/> object with a default length of 20 bytes.
/// </summary>
/// <remarks>
/// 20 bytes (160 bits) is the recommended key length specified by <a href="https://datatracker.ietf.org/doc/html/rfc4226#section-4">RFC 4226</a>.
/// Minimal recommended length is 16 bytes (128 bits).
/// </remarks>
/// <returns>A new random <see cref="OtpSecret"/> object.</returns>
public static OtpSecret CreateNew() =>
new();
/// <summary>
/// Creates a new random <see cref="OtpSecret"/> object with the specified length.
/// </summary>
/// <remarks>
/// 20 bytes (160 bits) is the recommended key length specified by <a href="https://datatracker.ietf.org/doc/html/rfc4226#section-4">RFC 4226</a>.
/// Minimal recommended length is 16 bytes (128 bits).
/// </remarks>
/// <param name="length">The length of the secret in bytes.</param>
/// <returns>A new random <see cref="OtpSecret"/> object.</returns>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="length"/> is less than 1.</exception>
public static OtpSecret CreateNew(int length) =>
new(length);
/// <summary>
/// Parses a Base32-encoded string into an <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <returns>An <see cref="OtpSecret"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="secret"/> is empty or contains invalid characters or only whitespace.</exception>
public static OtpSecret Parse(string secret) =>
new(secret);
/// <summary>
/// Parses a Base32-encoded string into an <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <param name="encoder">The encoder.</param>
/// <returns>An <see cref="OtpSecret"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentException"><paramref name="secret"/> is empty or contains invalid characters or only whitespace.</exception>
public static OtpSecret Parse(string secret, IEncoder encoder) =>
new(secret, encoder);
/// <summary>
/// Tries to parse a Base32-encoded string into an <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <param name="otpSecret">When this method returns, contains the <see cref="OtpSecret"/> object, if the conversion succeeded, or <c>default</c> if the conversion failed.</param>
/// <returns><c>true</c> if <paramref name="secret"/> was converted successfully; otherwise, <c>false</c>.</returns>
public static bool TryParse(string secret, out OtpSecret? otpSecret) =>
TryParse(secret, DefaultEncoder, out otpSecret);
/// <summary>
/// Tries to parse a Base32-encoded string into an <see cref="OtpSecret"/> object.
/// </summary>
/// <param name="secret">The Base32-encoded string.</param>
/// <param name="encoder">The encoder.</param>
/// <param name="otpSecret">When this method returns, contains the <see cref="OtpSecret"/> object, if the conversion succeeded, or <c>default</c> if the conversion failed.</param>
/// <returns><c>true</c> if <paramref name="secret"/> was converted successfully; otherwise, <c>false</c>.</returns>
public static bool TryParse(string secret, IEncoder encoder, out OtpSecret? otpSecret)
{
try
{
otpSecret = new(secret, encoder);
return true;
}
catch
{
otpSecret = null;
return false;
}
}
/// <summary>
/// Creates a new <see cref="OtpSecret"/> object from a byte array.
/// </summary>
/// <param name="secret">The byte array.</param>
/// <returns>An <see cref="OtpSecret"/> object.</returns>
/// <exception cref="ArgumentNullException"><paramref name="secret"/> is <c>null</c>.</exception>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="secret"/> is empty.</exception>
public static OtpSecret FromBytes(byte[] secret) =>
new(secret);
}
+22
View File
@@ -0,0 +1,22 @@
namespace SimpleOTP;
/// <summary>
/// Represents the type of One-Time Password (OTP).
/// </summary>
public enum OtpType
{
/// <summary>
/// Time-based One-Time Password (TOTP).
/// </summary>
/// <remarks>
/// <a href="https://tools.ietf.org/html/rfc6238">RFC 6238</a>
/// </remarks>
Totp = 0,
/// <summary>
/// HMAC-based One-Time Password (HOTP).
/// </summary>
/// <remarks>
/// <a href="https://tools.ietf.org/html/rfc4226">RFC 4226</a>
/// </remarks>
Hotp = 1
}
+67
View File
@@ -0,0 +1,67 @@
namespace SimpleOTP;
/// <summary>
/// Bitwise flags for specifying the format of One-Time Password (OTP) URIs.
/// </summary>
public enum OtpUriFormat : ushort
{
/// <summary>
/// Represents a minimal URI format - only non-default properties are included.
/// </summary>
/// <remarks>
/// This is the default format.
/// </remarks>
Minimal = 0b_0000_0001,
/// <summary>
/// Represents a full URI format - all properties are included.
/// </summary>
Full = 0b_0000_0010,
/// <summary>
/// Represents a Google URI format.
/// </summary>
/// <remarks>
/// This is the default format.<br />
/// <a href="https://github.com/google/google-authenticator/wiki/Key-Uri-Format">Google Authenticator. Key Uri Format</a>
/// </remarks>
Google = 0b_0001_0000,
/// <summary>
/// Represents an Apple URI format.
/// </summary>
/// <remarks>
/// <a href="https://developer.apple.com/documentation/authenticationservices/securing_logins_with_icloud_keychain_verification_codes">
/// Apple. Securing Logins with iCloud Keychain Verification Codes
/// </a>
/// </remarks>
Apple = 0b_0010_0000,
/// <summary>
/// Represents an IBM URI format.
/// </summary>
/// <remarks>
/// <a href="https://www.ibm.com/docs/en/sva/9.0.6?topic=authentication-configuring-totp-one-time-password-mechanism">
/// IBM. Authentication Configuring TOTP One-Time Password Mechanism
/// </a>
/// </remarks>
IBM = 0b_0100_0000,
/// <summary>
/// Represents a Yubico URI format.
/// </summary>
/// <remarks>
/// <a href="https://docs.yubico.com/yesdk/users-manual/application-oath/uri-string-format.html">
/// Yubico. URI String Format
/// </a>
/// </remarks>
Yubico = 0b_1000_0000,
/// <summary>
/// Represents an IIJ URI format.
/// </summary>
/// <remarks>
/// <a href="https://www1.auth.iij.jp/smartkey/en/uri_v1.html">Internet Initiative Japan. URI format</a>
/// </remarks>
IIJ = 0b_0001_0000_0000
}
+53
View File
@@ -0,0 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<PropertyGroup>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
<PropertyGroup>
<PackageId>EugeneFox.SimpleOTP</PackageId>
<Version>8.0.0.0-rc1</Version>
<Authors>Eugene Fox</Authors>
<Copyright>Copyright © Eugene Fox 2024</Copyright>
<NeutralLanguage>en-US</NeutralLanguage>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
</PropertyGroup>
<PropertyGroup>
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>README.md</PackageReadmeFile>
<RepositoryType>git</RepositoryType>
<RepositoryUrl>https://github.com/XFox111/SimpleOTP.git</RepositoryUrl>
<PackageProjectUrl>https://github.com/XFox111/SimpleOTP</PackageProjectUrl>
</PropertyGroup>
<PropertyGroup>
<PackageTags>otp;totp;hotp;authenticator;authentication;one-time;2fa;mfa;security;otpauth</PackageTags>
<Description>
Feature-rich, fast, and customizable library for implementation TOTP/HOTP authenticators and validators.
</Description>
<PackageReleaseNotes>
(BREAKING CHANGE) Complete overhaul of the library. See https://github.com/XFox111/SimpleOTP/releases/tag/2.0.0 for more details.
</PackageReleaseNotes>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\assets\icon.png">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath></PackagePath>
</None>
</ItemGroup>
</Project>
+59
View File
@@ -0,0 +1,59 @@
namespace SimpleOTP;
/// <summary>
/// Represents a span of tolerance values used in OTP (One-Time Password) validation.
/// </summary>
/// <param name="behind">The number of periods/counter values behind the current value.</param>
/// <param name="ahead">The number of periods/counter values ahead of the current value.</param>
public readonly struct ToleranceSpan(int behind, int ahead) : IEquatable<ToleranceSpan>
{
/// <summary>
/// Gets the default recommended <see cref="ToleranceSpan"/> value.
/// </summary>
/// <value>The default <see cref="ToleranceSpan"/> value: 1 counter/period ahead and behind.</value>
public static ToleranceSpan Default { get; } = new(1);
/// <summary>
/// Gets the number of tolerance values behind the current value.
/// </summary>
public int Behind { get; init; } = behind;
/// <summary>
/// Gets the number of tolerance values ahead of the current value.
/// </summary>
public int Ahead { get; init; } = ahead;
/// <summary>
/// Initializes a new instance of the <see cref="ToleranceSpan"/> struct with the specified tolerance value.
/// The <see cref="Behind"/> and <see cref="Ahead"/> properties will be set to the same value.
/// </summary>
/// <param name="tolerance">The tolerance value to set for both <see cref="Behind"/> and <see cref="Ahead"/>.</param>
public ToleranceSpan(int tolerance) : this(tolerance, tolerance) { }
/// <inheritdoc/>
public bool Equals(ToleranceSpan other) =>
Behind == other.Behind && Ahead == other.Ahead;
/// <inheritdoc/>
public override bool Equals(object? obj) =>
obj is ToleranceSpan span && Equals(span);
/// <inheritdoc/>
public override int GetHashCode() =>
HashCode.Combine(Behind, Ahead);
/// <summary>
/// Returns the string representation of the <see cref="ToleranceSpan"/> struct.
/// </summary>
/// <returns>The string representation of the <see cref="ToleranceSpan"/> struct.</returns>
public override string ToString() =>
$"(-{Behind}, +{Ahead})";
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
public static implicit operator ToleranceSpan(int tolerance) => new(tolerance);
public static implicit operator ToleranceSpan((int behind, int ahead) tolerance) => new(tolerance.behind, tolerance.ahead);
public static bool operator ==(ToleranceSpan left, ToleranceSpan right) => left.Equals(right);
public static bool operator !=(ToleranceSpan left, ToleranceSpan right) => !(left == right);
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member
}
+98
View File
@@ -0,0 +1,98 @@
namespace SimpleOTP;
/// <summary>
/// Represents a Time-based One-Time Password (TOTP) generator.
/// </summary>
public class Totp : Otp
{
/// <summary>
/// Gets or sets the time period (in seconds) for which each generated OTP is valid.
/// </summary>
/// <remarks>Also used to calculate the current counter value.</remarks>
public int Period { get; set; } = 30;
#region Constructors
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
public Totp(OtpSecret secret) : base(secret) { }
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key and number of OTP code digits.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="period">The time period (in seconds) for which each generated OTP is valid.</param>
public Totp(OtpSecret secret, int period) : base(secret) =>
Period = period;
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key, number of OTP code digits, and time period.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="period">The time period (in seconds) for which each generated OTP is valid.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Totp(OtpSecret secret, int period, int digits) : base(secret, digits) =>
Period = period;
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key, hash algorithm, and time period.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="period">The time period (in seconds) for which each generated OTP is valid.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
public Totp(OtpSecret secret, int period, OtpAlgorithm algorithm) : base(secret, algorithm) =>
Period = period;
/// <summary>
/// Initializes a new instance of the <see cref="Totp"/> class with the specified secret key, hash algorithm, number of digits, and time period.
/// </summary>
/// <param name="secret">The secret key used for generating OTP codes.</param>
/// <param name="period">The time period (in seconds) for which each generated OTP is valid.</param>
/// <param name="algorithm">The algorithm used for generating OTP codes.</param>
/// <param name="digits">The number of digits in the OTP code.</param>
public Totp(OtpSecret secret, int period, OtpAlgorithm algorithm, int digits) : base(secret, algorithm, digits) =>
Period = period;
#endregion
/// <summary>
/// Generates an OTP based on the specified counter value.
/// </summary>
/// <param name="counter">The counter value to use for OTP generation.</param>
/// <returns>An instance of <see cref="OtpCode"/> representing the generated OTP.</returns>
public override OtpCode Generate(long counter) =>
new(Compute(counter), Digits, DateTime.UnixEpoch.AddSeconds((counter + 1) * Period));
/// <summary>
/// Generates an OTP based on the specified date and time.
/// </summary>
/// <param name="date">The date and time to use for OTP generation.</param>
/// <returns>An instance of <see cref="OtpCode"/> representing the generated OTP.</returns>
public OtpCode Generate(DateTimeOffset date) =>
Generate(date.ToUnixTimeSeconds() / Period);
/// <summary>
/// Validates an OTP code with tolerance and base counter value, and returns the resynchronization value.
/// </summary>
/// <param name="code">The OTP code to validate.</param>
/// <param name="tolerance">The tolerance span for code validation.</param>
/// <param name="baseTime">The base timestamp value.</param>
/// <param name="resyncValue">The resynchronization value. Indicates how much given OTP code is ahead or behind the current counter value.</param>
/// <returns><c>true</c> if the OTP code is valid; otherwise, <c>false</c>.</returns>
/// <exception cref="InvalidOperationException">
/// Implementation for the <see cref="Otp.Algorithm"/> algorithm was not found.
/// Use <see cref="HashAlgorithmProviders.AddProvider(OtpAlgorithm)"/> to register an implementation.
/// </exception>
public bool Validate(OtpCode code, ToleranceSpan tolerance, DateTimeOffset baseTime, out int resyncValue) =>
Validate(code, tolerance, baseTime.ToUnixTimeSeconds() / 30, out resyncValue);
/// <summary>
/// Gets the current counter value based on the current UTC time and the configured time period.
/// </summary>
/// <returns>The current counter value.</returns>
protected override long GetCounter() =>
DateTimeOffset.UtcNow.ToUnixTimeSeconds() / Period;
}