diff --git a/Astral.Services/Constants/PublicKeyType.cs b/Astral.Services/Constants/PublicKeyType.cs new file mode 100644 index 0000000..846ff42 --- /dev/null +++ b/Astral.Services/Constants/PublicKeyType.cs @@ -0,0 +1,21 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +namespace Astral.Services.Constants; + +/// +/// Public key types. +/// +public enum PublicKeyType +{ + /// + /// SPKI/x509 format. + /// + SpkiX509PublicKey, + + /// + /// PKCS #1 type. + /// + Pkcs1PublicKey +} diff --git a/Astral.Services/Interfaces/ICryptographyService.cs b/Astral.Services/Interfaces/ICryptographyService.cs index f09911a..c1a2074 100644 --- a/Astral.Services/Interfaces/ICryptographyService.cs +++ b/Astral.Services/Interfaces/ICryptographyService.cs @@ -2,6 +2,8 @@ // Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. // +using Astral.Services.Constants; + namespace Astral.Services.Interfaces; /// @@ -48,4 +50,19 @@ public interface ICryptographyService /// Length of string to create. /// A random string. string GenerateRandomString(int length); + + /// + /// Convert public key. + /// + /// PKCS #1 key. + /// The type to convert to. + /// The converted key. + string ConvertPublicKey(byte[] pkcs1Key, PublicKeyType type); + + /// + /// Strip out header, footer and newlines from PEM RSA key. + /// + /// Key to simplify. + /// Simplified form. + string SimplifyPemKey(string pemKey); } diff --git a/Astral.Services/Services/CryptographyService.cs b/Astral.Services/Services/CryptographyService.cs index 56a4042..323e6a9 100644 --- a/Astral.Services/Services/CryptographyService.cs +++ b/Astral.Services/Services/CryptographyService.cs @@ -4,10 +4,12 @@ using System.Security.Cryptography; using System.Text; +using Astral.Services.Constants; using Astral.Services.Interfaces; using Astral.Services.Options; using Injectio.Attributes; using Konscious.Security.Cryptography; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Astral.Services.Services; @@ -17,14 +19,19 @@ namespace Astral.Services.Services; public class CryptographyService : ICryptographyService { private readonly PwdHashOptions _configuration; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// /// Instance of . - public CryptographyService(IOptions pwdHashSettings) + /// Instance of . + public CryptographyService( + IOptions pwdHashSettings, + ILogger logger) { _configuration = pwdHashSettings.Value; + _logger = logger; } /// @@ -67,4 +74,52 @@ public class CryptographyService : ICryptographyService const string availableChars = "ABCDEFGHIJKLMONOPQRSTUVWXYZabcdefghijklmonopqrstuvwxyz0123456789"; return RandomNumberGenerator.GetString(availableChars, length); } + + /// + public string ConvertPublicKey(byte[] pkcs1Key, PublicKeyType type) + { + try + { + var rsa = RSA.Create(); + var bytesRead = 0; + switch (type) + { + case PublicKeyType.SpkiX509PublicKey: + rsa.ImportSubjectPublicKeyInfo(pkcs1Key, out bytesRead); + break; + case PublicKeyType.Pkcs1PublicKey: + rsa.ImportRSAPublicKey(pkcs1Key, out bytesRead); + break; + } + var pem = ""; + if (bytesRead == 0) + { + _logger.LogError( + "An error occured converting RSA public key from binary to SPKI (PEM). Bytes read: 0"); + } + else + { + pem = rsa.ExportSubjectPublicKeyInfoPem(); + } + + rsa.Clear(); + return pem; + } + catch (Exception e) + { + _logger.LogError("An error occured converting RSA public key from binary to SPKI (PEM). {exception}", e); + } + + return string.Empty; + } + + /// + public string SimplifyPemKey(string pemKey) + { + pemKey = pemKey.Replace("-----BEGIN PUBLIC KEY-----", string.Empty); + pemKey = pemKey.Replace("-----END PUBLIC KEY-----", string.Empty); + pemKey = pemKey.Replace("\r", string.Empty); + pemKey = pemKey.Replace("\n", string.Empty); + return pemKey; + } } diff --git a/Astral.Tests/Astral.Tests.csproj b/Astral.Tests/Astral.Tests.csproj new file mode 100644 index 0000000..bd091ac --- /dev/null +++ b/Astral.Tests/Astral.Tests.csproj @@ -0,0 +1,42 @@ + + + + net8.0 + enable + ../Alveus.ruleset + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + Always + + + + diff --git a/Astral.Tests/GlobalUsings.cs b/Astral.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/Astral.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/Astral.Tests/ServiceTests/CryptographyServiceTests.cs b/Astral.Tests/ServiceTests/CryptographyServiceTests.cs new file mode 100644 index 0000000..5e9f13f --- /dev/null +++ b/Astral.Tests/ServiceTests/CryptographyServiceTests.cs @@ -0,0 +1,117 @@ +// +// Copyright (c) alveus.dev. All rights reserved. Licensed under the MIT License. +// + +using Astral.Services.Constants; +using Astral.Services.Interfaces; +using Astral.Services.Options; +using Astral.Services.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Astral.Tests.ServiceTests; + +/// +/// Tests for . +/// +public class CryptographyServiceTests +{ + private readonly CryptographyService _service; + + /// + /// Initializes a new instance of the class. + /// + public CryptographyServiceTests() + { + var pwdHashOptions = new PwdHashOptions() + { + DegreeOfParallelism = 4, + HashSize = 128, + SaltSize = 64, + MemoryToUseKb = 16, + NumberOfIterations = 3 + }; + + _service = new CryptographyService( + new OptionsWrapper(pwdHashOptions), + new NullLogger()); + } + + /// + /// Password hashing and verification tests. + /// + [Fact] + public void HashingAndVerificationTests() + { + const string password = "Password1234!"; + const string badPassword = "Password123!"; + + var salt1 = _service.GenerateSalt(); + var salt2 = _service.GenerateSalt(); + var salt3 = _service.GenerateSalt(); + + var hash1 = _service.HashPassword(password, salt1); + var hash2 = _service.HashPassword(password, salt2); + var hash3 = _service.HashPassword(password, salt3); + + Assert.Multiple(() => + { + Assert.NotNull(hash1); + Assert.NotNull(hash2); + Assert.NotNull(hash3); + }); + + // Ensure unique hashes. + Assert.Multiple(() => + { + Assert.NotEqual(hash1, hash2); + Assert.NotEqual(hash1, hash3); + Assert.NotEqual(hash2, hash3); + }); + + // Ensure passwords can be verified. + Assert.Multiple(() => + { + Assert.True(_service.VerifyPassword(password, salt1, hash1)); + Assert.True(_service.VerifyPassword(password, salt2, hash2)); + Assert.True(_service.VerifyPassword(password, salt3, hash3)); + }); + + // Ensure bad password/hash combinations do not get verified. + Assert.Multiple(() => + { + Assert.False(_service.VerifyPassword(password, salt1, hash2)); + Assert.False(_service.VerifyPassword(password, salt1, hash3)); + Assert.False(_service.VerifyPassword(password, salt2, hash1)); + Assert.False(_service.VerifyPassword(password, salt2, hash3)); + Assert.False(_service.VerifyPassword(password, salt3, hash1)); + Assert.False(_service.VerifyPassword(password, salt3, hash2)); + }); + + // Ensure bad password do not get verified. + Assert.Multiple(() => + { + Assert.False(_service.VerifyPassword(badPassword, salt1, hash1)); + Assert.False(_service.VerifyPassword(badPassword, salt2, hash2)); + Assert.False(_service.VerifyPassword(badPassword, salt3, hash3)); + }); + } + + /// + /// Public key conversion tests. + /// + [Fact] + public async Task PublicKeyConversionTests() + { + var publicKeyDer = await File.ReadAllBytesAsync("./TestData/public-key.der"); + var publicKeyPem = await File.ReadAllTextAsync("./TestData/public-key.pem"); + + publicKeyPem = _service.SimplifyPemKey(publicKeyPem); + + var generatedPem = _service.ConvertPublicKey(publicKeyDer, PublicKeyType.SpkiX509PublicKey); + + generatedPem = _service.SimplifyPemKey(generatedPem); + + Assert.Equal(generatedPem, publicKeyPem); + } +} diff --git a/Astral.Tests/TestData/public-key.der b/Astral.Tests/TestData/public-key.der new file mode 100644 index 0000000..dc967eb Binary files /dev/null and b/Astral.Tests/TestData/public-key.der differ diff --git a/Astral.Tests/TestData/public-key.pem b/Astral.Tests/TestData/public-key.pem new file mode 100644 index 0000000..76a5fc5 --- /dev/null +++ b/Astral.Tests/TestData/public-key.pem @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCQr8BExTMA6CREusv/SoiwlvMM +cvDNvPIuo0Tm70Kmm/Slle0pjszdHFbR3rUiniyhlmVIuu7hEkzMOIzE4KZ9cnOC +1DtkeEOhQkF2l3A4HX4OYPzUPzv89Hj+jAFI+LbBtzLb7wf2b4our+Z44w+i1YWN +ff59tv8PqXa/wuSD/QIDAQAB +-----END PUBLIC KEY----- diff --git a/AstralApi.sln b/AstralApi.sln index b5c4f2a..426f74e 100644 --- a/AstralApi.sln +++ b/AstralApi.sln @@ -15,6 +15,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution", "Solution", "{EB README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Astral.Tests", "Astral.Tests\Astral.Tests.csproj", "{F0CBECAA-F279-4D94-9F83-E9EBC7A5C08C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -37,5 +39,9 @@ Global {7594740E-7061-46C9-A870-7E3687D3BC36}.Debug|Any CPU.Build.0 = Debug|Any CPU {7594740E-7061-46C9-A870-7E3687D3BC36}.Release|Any CPU.ActiveCfg = Release|Any CPU {7594740E-7061-46C9-A870-7E3687D3BC36}.Release|Any CPU.Build.0 = Release|Any CPU + {F0CBECAA-F279-4D94-9F83-E9EBC7A5C08C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F0CBECAA-F279-4D94-9F83-E9EBC7A5C08C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F0CBECAA-F279-4D94-9F83-E9EBC7A5C08C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F0CBECAA-F279-4D94-9F83-E9EBC7A5C08C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal