diff --git a/LiteCharms.Features.MidrandBooks/Abstractions/IService.cs b/LiteCharms.Features.MidrandBooks/Abstractions/IService.cs deleted file mode 100644 index 6218faf..0000000 --- a/LiteCharms.Features.MidrandBooks/Abstractions/IService.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace LiteCharms.Features.MidrandBooks.Abstractions; - -public interface IService; diff --git a/LiteCharms.Features.MidrandBooks/AuthorBooks/BooksService.cs b/LiteCharms.Features.MidrandBooks/AuthorBooks/BooksService.cs index 545fe87..2ee001e 100644 --- a/LiteCharms.Features.MidrandBooks/AuthorBooks/BooksService.cs +++ b/LiteCharms.Features.MidrandBooks/AuthorBooks/BooksService.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; using LiteCharms.Features.MidrandBooks.AuthorBooks.Models; using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Postgres; diff --git a/LiteCharms.Features.MidrandBooks/Authors/AuthorService.cs b/LiteCharms.Features.MidrandBooks/Authors/AuthorService.cs index 3575214..0b30a85 100644 --- a/LiteCharms.Features.MidrandBooks/Authors/AuthorService.cs +++ b/LiteCharms.Features.MidrandBooks/Authors/AuthorService.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; using LiteCharms.Features.MidrandBooks.Authors.Models; using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Postgres; diff --git a/LiteCharms.Features.MidrandBooks/Categories/CategoryService.cs b/LiteCharms.Features.MidrandBooks/Categories/CategoryService.cs index 2a5b9f4..411f163 100644 --- a/LiteCharms.Features.MidrandBooks/Categories/CategoryService.cs +++ b/LiteCharms.Features.MidrandBooks/Categories/CategoryService.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; using LiteCharms.Features.MidrandBooks.Categories.Models; using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Postgres; diff --git a/LiteCharms.Features.MidrandBooks/Customers/CustomerService.cs b/LiteCharms.Features.MidrandBooks/Customers/CustomerService.cs index 03f34c0..5350220 100644 --- a/LiteCharms.Features.MidrandBooks/Customers/CustomerService.cs +++ b/LiteCharms.Features.MidrandBooks/Customers/CustomerService.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; using LiteCharms.Features.MidrandBooks.Customers.Models; using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Postgres; diff --git a/LiteCharms.Features.MidrandBooks/Extensions/Shop.cs b/LiteCharms.Features.MidrandBooks/Extensions/Shop.cs index f407c5b..fddb438 100644 --- a/LiteCharms.Features.MidrandBooks/Extensions/Shop.cs +++ b/LiteCharms.Features.MidrandBooks/Extensions/Shop.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; namespace LiteCharms.Features.MidrandBooks.Extensions; diff --git a/LiteCharms.Features.MidrandBooks/Orders/OrderService.cs b/LiteCharms.Features.MidrandBooks/Orders/OrderService.cs index 290908b..4ea6252 100644 --- a/LiteCharms.Features.MidrandBooks/Orders/OrderService.cs +++ b/LiteCharms.Features.MidrandBooks/Orders/OrderService.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Orders.Models; using LiteCharms.Features.MidrandBooks.Postgres; diff --git a/LiteCharms.Features.MidrandBooks/Pages/PageService.cs b/LiteCharms.Features.MidrandBooks/Pages/PageService.cs index 26569bc..22db7b7 100644 --- a/LiteCharms.Features.MidrandBooks/Pages/PageService.cs +++ b/LiteCharms.Features.MidrandBooks/Pages/PageService.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Pages.Models; using LiteCharms.Features.MidrandBooks.Postgres; diff --git a/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs b/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs index 5570fb8..3dd26dc 100644 --- a/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs +++ b/LiteCharms.Features.MidrandBooks/Payments/PaymentService.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Payments.Models; using LiteCharms.Features.MidrandBooks.Postgres; diff --git a/LiteCharms.Features.MidrandBooks/Products/ProductService.cs b/LiteCharms.Features.MidrandBooks/Products/ProductService.cs index 1682a70..771632b 100644 --- a/LiteCharms.Features.MidrandBooks/Products/ProductService.cs +++ b/LiteCharms.Features.MidrandBooks/Products/ProductService.cs @@ -1,4 +1,4 @@ -using LiteCharms.Features.MidrandBooks.Abstractions; +using LiteCharms.Features.Abstractions; using LiteCharms.Features.MidrandBooks.Categories.Models; using LiteCharms.Features.MidrandBooks.Extensions; using LiteCharms.Features.MidrandBooks.Postgres; diff --git a/LiteCharms.Features/Abstractions/IService.cs b/LiteCharms.Features/Abstractions/IService.cs new file mode 100644 index 0000000..17ec5e0 --- /dev/null +++ b/LiteCharms.Features/Abstractions/IService.cs @@ -0,0 +1,3 @@ +namespace LiteCharms.Features.Abstractions; + +public interface IService; diff --git a/LiteCharms.Features/Extensions/Hash.cs b/LiteCharms.Features/Extensions/Hash.cs index ca24629..6aa95ed 100644 --- a/LiteCharms.Features/Extensions/Hash.cs +++ b/LiteCharms.Features/Extensions/Hash.cs @@ -1,13 +1,23 @@ -namespace LiteCharms.Features.Extensions; +using LiteCharms.Features.Hasher; +using LiteCharms.Features.Hasher.Configuration; + +namespace LiteCharms.Features.Extensions; public static class Hash { - public static readonly Func StringToSha256Hash = (input) => - Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input!))); + public const string HasherConfigSectionName = "HasherSettings"; - public static readonly Func StreamToSha256Hash = (stream) => - Convert.ToHexString(SHA256.HashData(stream)); + public static IServiceCollection AddHashServices(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(HasherConfigSectionName)); - public static readonly Func BytesToSha256Hash = (bytes) => - Convert.ToHexString(SHA256.HashData(bytes)); -} + var settings = configuration.GetSection(HasherConfigSectionName).Get(); + + services.AddSingleton(_ => + new Hashids(settings!.Salt, minHashLength: settings.MinHashLength)); + + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/LiteCharms.Features/Hasher/Configuration/HasherSettings.cs b/LiteCharms.Features/Hasher/Configuration/HasherSettings.cs new file mode 100644 index 0000000..e30fb40 --- /dev/null +++ b/LiteCharms.Features/Hasher/Configuration/HasherSettings.cs @@ -0,0 +1,10 @@ +namespace LiteCharms.Features.Hasher.Configuration; + +public sealed class HasherSettings +{ + public string? Salt { get; set; } + + public int MinHashLength { get; set; } + + public string? PayfastPassphrase { get; set; } +} diff --git a/LiteCharms.Features/Hasher/HashService.cs b/LiteCharms.Features/Hasher/HashService.cs new file mode 100644 index 0000000..853ccb8 --- /dev/null +++ b/LiteCharms.Features/Hasher/HashService.cs @@ -0,0 +1,118 @@ +using LiteCharms.Features.Abstractions; +using LiteCharms.Features.Hasher.Configuration; + +namespace LiteCharms.Features.Hasher; + +public sealed partial class HashService(IHashids hasher, IOptions options) : IService +{ + private readonly HasherSettings settings = options.Value; + + [System.Text.RegularExpressions.GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z")] + private static partial System.Text.RegularExpressions.Regex HexHashRegex(); + + public static readonly Func StringToSha256Hash = (input) => + string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))); + + public static readonly Func StreamToSha256Hash = (stream) => + stream is null ? null : Convert.ToHexString(SHA256.HashData(stream)); + + public static readonly Func BytesToSha256Hash = (bytes) => + bytes is null ? null : Convert.ToHexString(SHA256.HashData(bytes)); + + public static Result ComputeMd5Hash(string input) + { + if (string.IsNullOrEmpty(input)) + return Result.Fail("Input content cannot be null or empty for MD5 processing."); + + byte[] bytes = MD5.HashData(Encoding.UTF8.GetBytes(input)); + return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant()); + } + + public Result VerifyPayfastWebhookSignature(IDictionary incomingFormData, string incomingSignature) + { + try + { + if (string.IsNullOrWhiteSpace(incomingSignature)) + return Result.Fail("Validation failed: Missing signature string parameter."); + + // 1. Sort the parameters alphabetically and exclude the signature parameter to prevent recursive checking + var sortedFields = incomingFormData + .Where(field => field.Key != "signature") + .OrderBy(field => field.Key) + .Select(field => $"{field.Key}={Uri.EscapeDataString(field.Value).Replace("%20", "+")}"); + + string payload = string.Join("&", sortedFields); + + // 2. Append the secure, passphrase injected into the container pod from your environment variables + if (!string.IsNullOrWhiteSpace(settings.PayfastPassphrase)) + { + payload += $"&passphrase={Uri.EscapeDataString(settings.PayfastPassphrase).Replace("%20", "+")}"; + } + + // 3. Compute localized hex token + var localHashResult = ComputeMd5Hash(payload); + + if (!localHashResult.IsSuccess) + return Result.Fail(localHashResult.Errors); + + // 4. Constant-time secure text comparison to fully block timing analysis attacks + bool isValid = string.Equals(localHashResult.Value, incomingSignature, StringComparison.OrdinalIgnoreCase); + + return Result.Ok(isValid); + } + catch (Exception ex) + { + return Result.Fail(new Error("An error occurred during MD5 verification loop.").CausedBy(ex)); + } + } + + public Result HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex().IsMatch(input) + ? Result.Fail("Input must be a valid hexadecimal string.") + : Result.Ok(hasher.EncodeHex(input)); + + public Result HashEncodeIntId(int id) => id < 0 + ? Result.Fail("Id cannot be negative.") + : Result.Ok(hasher.Encode(id)); + + public Result HashEncodeLongId(long id) => id < 0 + ? Result.Fail("Id cannot be negative.") + : Result.Ok(hasher.EncodeLong(id)); + + public Result DecodeIntIdHash(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) return Result.Fail("Invalid token layout."); + + int[] decoded = hasher.Decode(hash); + + return decoded.Length == 1 ? Result.Ok(decoded[0]) : Result.Fail("Invalid or modified Int hash token."); + } + + public Result DecodeLongIdHash(string hash) + { + if (string.IsNullOrWhiteSpace(hash)) return Result.Fail("Invalid token layout."); + + long[] decoded = hasher.DecodeLong(hash); + + return decoded.Length == 1 ? Result.Ok(decoded[0]) : Result.Fail("Invalid or modified Long hash token."); + } + + public Result DecodeHexHash(string hex) + { + try + { + string decoded = hasher.DecodeHex(hex); + + return string.IsNullOrEmpty(decoded) + ? Result.Fail("Invalid or corrupted hex hash.") + : Result.Ok(decoded); + } + catch (FormatException fex) + { + return Result.Fail(new Error("Invalid hash structure.").CausedBy(fex)); + } + catch (Exception ex) + { + return Result.Fail(new Error(ex.Message).CausedBy(ex)); + } + } +} \ No newline at end of file diff --git a/LiteCharms.Features/LiteCharms.Features.csproj b/LiteCharms.Features/LiteCharms.Features.csproj index 552ee0a..6079860 100644 --- a/LiteCharms.Features/LiteCharms.Features.csproj +++ b/LiteCharms.Features/LiteCharms.Features.csproj @@ -31,6 +31,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -146,6 +147,7 @@ + diff --git a/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs b/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs index c1b9ae4..3fcb5d8 100644 --- a/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs +++ b/LiteCharms.Features/S3/Abstractions/S3ServiceBase.cs @@ -1,4 +1,4 @@ -using static LiteCharms.Features.Extensions.Hash; +using LiteCharms.Features.Hasher; namespace LiteCharms.Features.S3.Abstractions; @@ -26,7 +26,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) stream.Seek(0, SeekOrigin.Begin); - var fileHash = StreamToSha256Hash(stream); + var fileHash = HashService.StreamToSha256Hash(stream); if(string.IsNullOrWhiteSpace(fileHash)) return Result.Fail("Failed to compute file hash."); @@ -39,7 +39,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) Key = fileKey, InputStream = stream, ContentType = contentType, - UseChunkEncoding = false + UseChunkEncoding = false, }; stream.Seek(0, SeekOrigin.Begin);