diff --git a/README.md b/README.md index bbd775f..ea39298 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ - [Checksum Utility](#checksum-utility) - [Password Hasher](#password-hasher) - [JWT Generator](#jwt-generator) + - [JWK Generator](#jwk-generator) + - [JWS Generator](#jws-generator) - [TOTP Generator](#totp-generator) - [Web API Models](#web-api-models) - [Sagas](#sagas) @@ -964,6 +966,62 @@ JwtGenerator.TryGenerateToken(secret, issuer, audience, 60, "user", roles, out v --- +### JWK Generator + +The `JwkGenerator` class provides methods for generating and managing JSON Web Keys (JWK) for cryptographic operations. It supports RSA, EC, and symmetric keys, and provides thumbprint computation and key serialization. + +#### Features +- Key Generation: Generate RSA, EC, and symmetric (octet) JWKs, with or without private key material. +- Thumbprint Computation: Compute RFC 7638-compliant thumbprints for JWKs. +- Key Serialization: Export and import JWKs for interoperability. +- Try Pattern: All methods use the Try-pattern for safe error handling. + +#### Example Usage +```csharp +// Generate a new RSA JWK (public only) +JwkGenerator.TryGenerateRsa(2048, false, null, null, null, out var jwk, out var error); +// Generate a new EC JWK (private) +JwkGenerator.TryGenerateEc(JwkCurve.P256, true, null, null, null, out var ecJwk, out var error); +// Compute a thumbprint +JwkGenerator.TryComputeThumbprint(jwk, out var thumbprint, out var error); +``` + +--- + +### JWS Generator + +The `JwsGenerator` class provides methods for creating and verifying JSON Web Signatures (JWS) using JWKs. It supports signing payloads with RSA keys and produces JWS objects with protected headers, payload, and signature. + +#### Features +- JWS Creation: Sign string, byte[], or object payloads using RSA JWKs. +- Try Pattern: All methods use the Try-pattern for safe error handling. +- Key Authorization: Generate key authorization strings for ACME/Let's Encrypt flows. + +#### Example Usage +```csharp +// Sign a payload +JwsGenerator.TryEncode(rsa, jwk, new JwsHeader(), "payload", out var jws, out var error); +// Generate key authorization +JwsGenerator.TryGetKeyAuthorization(jwk, "token", out var keyAuth, out var error); +``` + +--- + +### JWT Generator + +The `JwtGenerator` class provides methods for generating and validating JSON Web Tokens (JWTs). + +#### Features +- Token Generation: Generate JWTs with claims and metadata. +- Token Validation: Validate JWTs against a secret. + +#### Example Usage +```csharp +JwtGenerator.TryGenerateToken(secret, issuer, audience, 60, "user", roles, out var token, out var error); +``` + +--- + ### TOTP Generator The `TotpGenerator` class provides methods for generating and validating Time-Based One-Time Passwords (TOTP). diff --git a/src/MaksIT.Core.Tests/Security/JwkGeneratorTests.cs b/src/MaksIT.Core.Tests/Security/JwkGeneratorTests.cs new file mode 100644 index 0000000..bf10eeb --- /dev/null +++ b/src/MaksIT.Core.Tests/Security/JwkGeneratorTests.cs @@ -0,0 +1,107 @@ +using MaksIT.Core.Security.JWK; + +namespace MaksIT.Core.Tests.Security; + +public class JwkGeneratorTests +{ + [Fact] + public void GenerateRsa_PublicKey_ShouldHaveRequiredFields() + { + var result = JwkGenerator.TryGenerateRsa(2048, false, null, null, null, out var jwk, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jwk); + Assert.Equal(JwkKeyType.Rsa.Name, jwk.Kty); + Assert.NotNull(jwk.N); + Assert.NotNull(jwk.E); + Assert.Null(jwk.D); + Assert.Null(jwk.P); + Assert.Null(jwk.Q); + Assert.Null(jwk.DP); + Assert.Null(jwk.DQ); + Assert.Null(jwk.QI); + Assert.False(string.IsNullOrWhiteSpace(jwk.Kid)); + } + + [Fact] + public void GenerateRsa_PrivateKey_ShouldHavePrivateFields() + { + var result = JwkGenerator.TryGenerateRsa(2048, true, null, null, null, out var jwk, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jwk); + Assert.Equal(JwkKeyType.Rsa.Name, jwk.Kty); + Assert.NotNull(jwk.D); + Assert.NotNull(jwk.P); + Assert.NotNull(jwk.Q); + Assert.NotNull(jwk.DP); + Assert.NotNull(jwk.DQ); + Assert.NotNull(jwk.QI); + } + + [Theory] + [InlineData("P-256")] + [InlineData("P-384")] + [InlineData("P-521")] + public void GenerateEc_PublicKey_ShouldHaveRequiredFields(string curve) + { + var curveObj = JwkCurve.GetAll().First(c => c.Name == curve); + var result = JwkGenerator.TryGenerateEc(curveObj, false, null, null, null, out var jwk, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jwk); + Assert.Equal(JwkKeyType.Ec.Name, jwk.Kty); + Assert.Equal(curve, jwk.Crv); + Assert.NotNull(jwk.X); + Assert.NotNull(jwk.Y); + Assert.Null(jwk.D_EC); + Assert.False(string.IsNullOrWhiteSpace(jwk.Kid)); + } + + [Theory] + [InlineData("P-256")] + [InlineData("P-384")] + [InlineData("P-521")] + public void GenerateEc_PrivateKey_ShouldHavePrivateField(string curve) + { + var curveObj = JwkCurve.GetAll().First(c => c.Name == curve); + var result = JwkGenerator.TryGenerateEc(curveObj, true, null, null, null, out var jwk, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jwk); + Assert.Equal(JwkKeyType.Ec.Name, jwk.Kty); + Assert.Equal(curve, jwk.Crv); + Assert.NotNull(jwk.D_EC); + } + + [Fact] + public void GenerateOct_ShouldHaveRequiredFields() + { + var result = JwkGenerator.TryGenerateOct(256, null, null, null, out var jwk, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jwk); + Assert.Equal(JwkKeyType.Oct.Name, jwk.Kty); + Assert.NotNull(jwk.K); + Assert.False(string.IsNullOrWhiteSpace(jwk.Kid)); + } + + [Fact] + public void TryComputeThumbprint_ShouldReturnValidThumbprint() + { + var result = JwkGenerator.TryGenerateRsa(2048, false, null, null, null, out var jwk, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jwk); + var thumbResult = JwkGenerator.TryComputeThumbprint(jwk, out var thumb, out var error); + Assert.True(thumbResult, error); + Assert.False(string.IsNullOrWhiteSpace(thumb)); + Assert.Null(error); + } + + [Fact] + public void ComputeKid_ShouldBeUniqueForDifferentKeys() + { + var result1 = JwkGenerator.TryGenerateRsa(2048, false, null, null, null, out var jwk1, out var errorMessage1); + var result2 = JwkGenerator.TryGenerateRsa(2048, false, null, null, null, out var jwk2, out var errorMessage2); + Assert.True(result1, errorMessage1); + Assert.True(result2, errorMessage2); + Assert.NotNull(jwk1); + Assert.NotNull(jwk2); + Assert.NotEqual(jwk1.Kid, jwk2.Kid); + } +} diff --git a/src/MaksIT.Core.Tests/Security/JwsGeneratorTests.cs b/src/MaksIT.Core.Tests/Security/JwsGeneratorTests.cs new file mode 100644 index 0000000..1e7aa0f --- /dev/null +++ b/src/MaksIT.Core.Tests/Security/JwsGeneratorTests.cs @@ -0,0 +1,84 @@ +using System.Text; +using System.Security.Cryptography; +using MaksIT.Core.Security.JWK; +using MaksIT.Core.Security.JWS; + + +namespace MaksIT.Core.Tests.Security; + +public class JwsGeneratorTests { + private static (RSA rsa, Jwk jwk) GenerateRsaAndJwk() { + var rsa = RSA.Create(2048); + var result = JwkGenerator.TryGenerateRsaFromRsa(rsa, true, null, null, null, out var jwk, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jwk); + return (rsa, jwk); + } + + [Fact] + public void Encode_WithStringPayload_ProducesValidJws() { + var (rsa, jwk) = GenerateRsaAndJwk(); + var header = new JwsHeader(); + var payload = "test-payload"; + + var result = JwsGenerator.TryEncode(rsa, jwk, header, payload, out var jws, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jws); + Assert.False(string.IsNullOrEmpty(jws.Protected)); + Assert.False(string.IsNullOrEmpty(jws.Payload)); + Assert.False(string.IsNullOrEmpty(jws.Signature)); + } + + [Fact] + public void Encode_WithByteArrayPayload_ProducesValidJws() { + var (rsa, jwk) = GenerateRsaAndJwk(); + var header = new JwsHeader(); + var payload = Encoding.UTF8.GetBytes("test-bytes"); + + var result = JwsGenerator.TryEncode(rsa, jwk, header, payload, out var jws, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jws); + Assert.False(string.IsNullOrEmpty(jws.Protected)); + Assert.False(string.IsNullOrEmpty(jws.Payload)); + Assert.False(string.IsNullOrEmpty(jws.Signature)); + } + + [Fact] + public void Encode_WithGenericPayload_ProducesValidJws() { + var (rsa, jwk) = GenerateRsaAndJwk(); + var header = new JwsHeader(); + var payload = new { foo = "bar", n = 42 }; + + var result = JwsGenerator.TryEncode(rsa, jwk, header, payload, out var jws, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jws); + Assert.False(string.IsNullOrEmpty(jws.Protected)); + Assert.False(string.IsNullOrEmpty(jws.Payload)); + Assert.False(string.IsNullOrEmpty(jws.Signature)); + } + + [Fact] + public void Encode_PostAsGet_ProducesValidJws() { + var (rsa, jwk) = GenerateRsaAndJwk(); + var header = new JwsHeader(); + + var result = JwsGenerator.TryEncode(rsa, jwk, header, out var jws, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(jws); + Assert.False(string.IsNullOrEmpty(jws.Protected)); + Assert.Equal(string.Empty, jws.Payload); + Assert.False(string.IsNullOrEmpty(jws.Signature)); + } + + [Fact] + public void GetKeyAuthorization_ReturnsExpectedFormat() { + var (rsa, jwk) = GenerateRsaAndJwk(); + var token = "test-token"; + + var result = JwsGenerator.TryGetKeyAuthorization(jwk, token, out var keyAuth, out var errorMessage); + Assert.True(result, errorMessage); + Assert.NotNull(keyAuth); + Assert.StartsWith(token + ".", keyAuth); + Assert.True(keyAuth.Length > token.Length + 1); + } +} diff --git a/src/MaksIT.Core/MaksIT.Core.csproj b/src/MaksIT.Core/MaksIT.Core.csproj index 16298de..ee16bd0 100644 --- a/src/MaksIT.Core/MaksIT.Core.csproj +++ b/src/MaksIT.Core/MaksIT.Core.csproj @@ -8,7 +8,7 @@ MaksIT.Core - 1.5.3 + 1.5.4 Maksym Sadovnychyy MAKS-IT MaksIT.Core diff --git a/src/MaksIT.Core/Security/JWK/Base64UrlUtility.cs b/src/MaksIT.Core/Security/JWK/Base64UrlUtility.cs new file mode 100644 index 0000000..e4ce427 --- /dev/null +++ b/src/MaksIT.Core/Security/JWK/Base64UrlUtility.cs @@ -0,0 +1,55 @@ +using System; +using System.Buffers; +using System.Text; + +namespace MaksIT.Core.Security.JWK; + +/// +/// Provides RFC 4648-compliant Base64Url encoding and decoding utilities. +/// +public static class Base64UrlUtility +{ + /// + /// Encodes a byte array to a Base64Url string (RFC 4648 §5). + /// + public static string Encode(byte[] data) + { + if (data == null) throw new ArgumentNullException(nameof(data)); + string base64 = Convert.ToBase64String(data); + return base64.TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + /// + /// Encodes a UTF-8 string to a Base64Url string (RFC 4648 §5). + /// + public static string Encode(string value) + { + if (value == null) throw new ArgumentNullException(nameof(value)); + return Encode(Encoding.UTF8.GetBytes(value)); + } + + /// + /// Decodes a Base64Url string to a byte array. + /// + public static byte[] Decode(string base64Url) + { + if (base64Url == null) throw new ArgumentNullException(nameof(base64Url)); + string padded = base64Url.Replace('-', '+').Replace('_', '/'); + switch (base64Url.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + return Convert.FromBase64String(padded); + } + + /// + /// Decodes a Base64Url string to a UTF-8 string. + /// + public static string DecodeToString(string base64Url) + { + return Encoding.UTF8.GetString(Decode(base64Url)); + } +} diff --git a/src/MaksIT.Core/Security/JWK/Jwk.cs b/src/MaksIT.Core/Security/JWK/Jwk.cs new file mode 100644 index 0000000..97bc897 --- /dev/null +++ b/src/MaksIT.Core/Security/JWK/Jwk.cs @@ -0,0 +1,73 @@ +using System.Text.Json.Serialization; + + +namespace MaksIT.Core.Security.JWK; + +/// +/// Standard JWK class supporting RSA, EC, and octet keys. +/// +public class Jwk { + // Common fields + [JsonPropertyName("kty")] + public string? Kty { get; set; } + + [JsonPropertyName("kid")] + public string? Kid { get; set; } + + [JsonPropertyName("alg")] + public string? Alg { get; set; } + + [JsonPropertyName("use")] + public string? Use { get; set; } + + [JsonPropertyName("key_ops")] + public string[]? KeyOps { get; set; } + + // RSA fields + [JsonPropertyName("n")] + public string? N { get; set; } // Modulus + + [JsonPropertyName("e")] + public string? E { get; set; } // Exponent + + [JsonPropertyName("d")] + public string? D { get; set; } // Private exponent + + [JsonPropertyName("p")] + public string? P { get; set; } + + [JsonPropertyName("q")] + public string? Q { get; set; } + + [JsonPropertyName("dp")] + public string? DP { get; set; } + + [JsonPropertyName("dq")] + public string? DQ { get; set; } + + [JsonPropertyName("qi")] + public string? QI { get; set; } + + // EC fields + [JsonPropertyName("crv")] + public string? Crv { get; set; } + + [JsonPropertyName("x")] + public string? X { get; set; } + + [JsonPropertyName("y")] + public string? Y { get; set; } + + [JsonPropertyName("d_ec")] + public string? D_EC { get; set; } // EC private key + + // Symmetric (octet) fields + [JsonPropertyName("k")] + public string? K { get; set; } + + // Backward compatibility for old code + [JsonIgnore] + public string? Exponent { get => E; set => E = value; } + [JsonIgnore] + public string? Modulus { get => N; set => N = value; } +} \ No newline at end of file diff --git a/src/MaksIT.Core/Security/JWK/JwkAlgorithm.cs b/src/MaksIT.Core/Security/JWK/JwkAlgorithm.cs new file mode 100644 index 0000000..9dc1727 --- /dev/null +++ b/src/MaksIT.Core/Security/JWK/JwkAlgorithm.cs @@ -0,0 +1,17 @@ +using MaksIT.Core.Abstractions; + + +namespace MaksIT.Core.Security.JWK; + +public sealed class JwkAlgorithm : Enumeration { + public static readonly JwkAlgorithm Rs256 = new(1, "RS256"); + public static readonly JwkAlgorithm Rs512 = new(2, "RS512"); + public static readonly JwkAlgorithm Es256 = new(3, "ES256"); + public static readonly JwkAlgorithm Es384 = new(4, "ES384"); + public static readonly JwkAlgorithm Es512 = new(5, "ES512"); + public static readonly JwkAlgorithm A128Gcm = new(6, "A128GCM"); + public static readonly JwkAlgorithm A256Gcm = new(7, "A256GCM"); + public static readonly JwkAlgorithm A512Gcm = new(8, "A512GCM"); + + private JwkAlgorithm(int id, string name) : base(id, name) { } +} \ No newline at end of file diff --git a/src/MaksIT.Core/Security/JWK/JwkCurve.cs b/src/MaksIT.Core/Security/JWK/JwkCurve.cs new file mode 100644 index 0000000..e3d3e67 --- /dev/null +++ b/src/MaksIT.Core/Security/JWK/JwkCurve.cs @@ -0,0 +1,12 @@ +using MaksIT.Core.Abstractions; + + +namespace MaksIT.Core.Security.JWK; + +public sealed class JwkCurve : Enumeration { + public static readonly JwkCurve P256 = new(1, "P-256"); + public static readonly JwkCurve P384 = new(2, "P-384"); + public static readonly JwkCurve P521 = new(3, "P-521"); + + private JwkCurve(int id, string name) : base(id, name) { } +} \ No newline at end of file diff --git a/src/MaksIT.Core/Security/JWK/JwkGenerator.cs b/src/MaksIT.Core/Security/JWK/JwkGenerator.cs new file mode 100644 index 0000000..b842e05 --- /dev/null +++ b/src/MaksIT.Core/Security/JWK/JwkGenerator.cs @@ -0,0 +1,213 @@ +using System.Text; +using System.Security.Cryptography; +using System.Text.Json.Serialization; +using System.Diagnostics.CodeAnalysis; +using MaksIT.Core.Extensions; + +namespace MaksIT.Core.Security.JWK; + +/// +/// Provides utilities for JWK (JSON Web Key) operations, including RFC 7638 thumbprint computation and key generation. +/// +public static class JwkGenerator { + public static bool TryGenerateRsa(int keySize, bool includePrivate, JwkAlgorithm? alg, string? use, string[]? keyOps, [NotNullWhen(true)] out Jwk? jwk, [NotNullWhen(false)] out string? errorMessage) { + try { + jwk = GenerateRsa(keySize, includePrivate, alg, use, keyOps); + errorMessage = null; + return true; + } catch (Exception ex) { + jwk = null; + errorMessage = ex.Message; + return false; + } + } + + public static bool TryGenerateEc(JwkCurve? curve, bool includePrivate, JwkAlgorithm? alg, string? use, string[]? keyOps, [NotNullWhen(true)] out Jwk? jwk, [NotNullWhen(false)] out string? errorMessage) { + try { + jwk = GenerateEc(curve, includePrivate, alg, use, keyOps); + errorMessage = null; + return true; + } catch (Exception ex) { + jwk = null; + errorMessage = ex.Message; + return false; + } + } + + public static bool TryGenerateOct(int keySizeBits, JwkAlgorithm? alg, string? use, string[]? keyOps, [NotNullWhen(true)] out Jwk? jwk, [NotNullWhen(false)] out string? errorMessage) { + try { + jwk = GenerateOct(keySizeBits, alg, use, keyOps); + errorMessage = null; + return true; + } catch (Exception ex) { + jwk = null; + errorMessage = ex.Message; + return false; + } + } + + public static bool TryGenerateRsaFromRsa(RSA rsa, bool includePrivate, JwkAlgorithm? alg, string? use, string[]? keyOps, [NotNullWhen(true)] out Jwk? jwk, [NotNullWhen(false)] out string? errorMessage) { + try { + jwk = GenerateRsaFromRsa(rsa, includePrivate, alg, use, keyOps); + errorMessage = null; + return true; + } catch (Exception ex) { + jwk = null; + errorMessage = ex.Message; + return false; + } + } + + public static bool TryComputeThumbprint( + Jwk jwk, + [NotNullWhen(true)] out string? thumbprint, + [NotNullWhen(false)] out string? errorMessage) { + thumbprint = null; + errorMessage = null; + + if (jwk == null) { + errorMessage = "JWK cannot be null."; + return false; + } + if (string.IsNullOrEmpty(jwk.E) || string.IsNullOrEmpty(jwk.N)) { + errorMessage = "JWK must have Exponent and Modulus set."; + return false; + } + + try { + // RFC 7638: Lexicographic order: e, kty, n + var orderedJwk = new OrderedJwk { + E = jwk.E!, + Kty = "RSA", + N = jwk.N! + }; + + var json = orderedJwk.ToJson(); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json)); + thumbprint = Base64UrlEncode(hash); + return true; + } + catch (Exception ex) { + errorMessage = ex.Message; + return false; + } + } + + private static Jwk GenerateRsa(int keySize = 2048, bool includePrivate = false, JwkAlgorithm? alg = null, string? use = null, string[]? keyOps = null) { + using var rsa = RSA.Create(keySize); + var parameters = rsa.ExportParameters(includePrivate); + var jwk = new Jwk { + Kty = JwkKeyType.Rsa.Name, + N = Base64UrlEncode(parameters.Modulus!), + E = Base64UrlEncode(parameters.Exponent!), + Alg = (alg ?? (keySize >= 4096 ? JwkAlgorithm.Rs512 : JwkAlgorithm.Rs256)).Name, + Use = use, + KeyOps = keyOps, + }; + if (includePrivate) { + jwk.D = Base64UrlEncode(parameters.D!); + jwk.P = Base64UrlEncode(parameters.P!); + jwk.Q = Base64UrlEncode(parameters.Q!); + jwk.DP = Base64UrlEncode(parameters.DP!); + jwk.DQ = Base64UrlEncode(parameters.DQ!); + jwk.QI = Base64UrlEncode(parameters.InverseQ!); + } + jwk.Kid = ComputeKid(jwk); + return jwk; + } + + private static Jwk GenerateEc(JwkCurve? curve = null, bool includePrivate = false, JwkAlgorithm? alg = null, string? use = null, string[]? keyOps = null) { + curve ??= JwkCurve.P256; + ECCurve ecCurve = curve.Name switch { + "P-256" => ECCurve.NamedCurves.nistP256, + "P-384" => ECCurve.NamedCurves.nistP384, + "P-521" => ECCurve.NamedCurves.nistP521, + _ => throw new ArgumentException($"Unsupported curve: {curve.Name}") + }; + using var ec = ECDsa.Create(ecCurve); + var parameters = ec.ExportParameters(includePrivate); + var jwk = new Jwk { + Kty = JwkKeyType.Ec.Name, + Crv = curve.Name, + X = Base64UrlEncode(parameters.Q.X!), + Y = Base64UrlEncode(parameters.Q.Y!), + Alg = (alg ?? (curve == JwkCurve.P384 ? JwkAlgorithm.Es384 : curve == JwkCurve.P521 ? JwkAlgorithm.Es512 : JwkAlgorithm.Es256)).Name, + Use = use, + KeyOps = keyOps, + }; + if (includePrivate && parameters.D != null) { + jwk.D_EC = Base64UrlEncode(parameters.D); + } + jwk.Kid = ComputeKid(jwk); + return jwk; + } + + private static Jwk GenerateOct(int keySizeBits = 256, JwkAlgorithm? alg = null, string? use = null, string[]? keyOps = null) { + var key = RandomNumberGenerator.GetBytes(keySizeBits / 8); + var jwk = new Jwk { + Kty = JwkKeyType.Oct.Name, + K = Base64UrlEncode(key), + Alg = (alg ?? (keySizeBits == 256 ? JwkAlgorithm.A256Gcm : keySizeBits == 128 ? JwkAlgorithm.A128Gcm : JwkAlgorithm.A512Gcm)).Name, + Use = use, + KeyOps = keyOps, + }; + jwk.Kid = ComputeKid(jwk); + return jwk; + } + + private static Jwk GenerateRsaFromRsa(RSA rsa, bool includePrivate = false, JwkAlgorithm? alg = null, string? use = null, string[]? keyOps = null) { + if (rsa == null) throw new ArgumentNullException(nameof(rsa)); + var parameters = rsa.ExportParameters(includePrivate); + var jwk = new Jwk { + Kty = JwkKeyType.Rsa.Name, + N = Base64UrlUtility.Encode(parameters.Modulus!), + E = Base64UrlUtility.Encode(parameters.Exponent!), + Alg = (alg ?? JwkAlgorithm.Rs256).Name, + Use = use, + KeyOps = keyOps, + }; + if (includePrivate) { + jwk.D = Base64UrlUtility.Encode(parameters.D!); + jwk.P = Base64UrlUtility.Encode(parameters.P!); + jwk.Q = Base64UrlUtility.Encode(parameters.Q!); + jwk.DP = Base64UrlUtility.Encode(parameters.DP!); + jwk.DQ = Base64UrlUtility.Encode(parameters.DQ!); + jwk.QI = Base64UrlUtility.Encode(parameters.InverseQ!); + } + jwk.Kid = ComputeKid(jwk); + return jwk; + } + + private static string Base64UrlEncode(byte[] data) { + return Base64UrlUtility.Encode(data); + } + + private static string ComputeKid(Jwk jwk) { + // Use thumbprint as kid if possible + if (jwk.Kty == "RSA" && !string.IsNullOrEmpty(jwk.N) && !string.IsNullOrEmpty(jwk.E)) { + TryComputeThumbprint(jwk, out var thumb, out _); + return thumb ?? Guid.NewGuid().ToString("N"); + } + // For EC and oct, use a hash of the key material + using var sha = SHA256.Create(); + string keyMaterial = jwk.Kty switch { + "EC" => jwk.X + jwk.Y + jwk.Crv, + "oct" => jwk.K, + _ => null + } ?? Guid.NewGuid().ToString(); + var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(keyMaterial)); + return Base64UrlEncode(hash); + } + + // Helper class for correct property order and names + private class OrderedJwk { + [JsonPropertyName("e")] + public string E { get; set; } = default!; + + [JsonPropertyName("kty")] + public string Kty { get; set; } = default!; + + [JsonPropertyName("n")] + public string N { get; set; } = default!; + } +} diff --git a/src/MaksIT.Core/Security/JWK/JwkKeyType.cs b/src/MaksIT.Core/Security/JWK/JwkKeyType.cs new file mode 100644 index 0000000..c0c1be2 --- /dev/null +++ b/src/MaksIT.Core/Security/JWK/JwkKeyType.cs @@ -0,0 +1,12 @@ +using MaksIT.Core.Abstractions; + + +namespace MaksIT.Core.Security.JWK; + +public sealed class JwkKeyType : Enumeration { + public static readonly JwkKeyType Rsa = new(1, "RSA"); + public static readonly JwkKeyType Ec = new(2, "EC"); + public static readonly JwkKeyType Oct = new(3, "oct"); + + private JwkKeyType(int id, string name) : base(id, name) { } +} \ No newline at end of file diff --git a/src/MaksIT.Core/Security/JWS/JwsGenerator.cs b/src/MaksIT.Core/Security/JWS/JwsGenerator.cs new file mode 100644 index 0000000..dbd4dc5 --- /dev/null +++ b/src/MaksIT.Core/Security/JWS/JwsGenerator.cs @@ -0,0 +1,117 @@ +using System.Text; +using System.Security.Cryptography; +using System.Diagnostics.CodeAnalysis; +using MaksIT.Core.Security.JWK; +using MaksIT.Core.Extensions; + + +namespace MaksIT.Core.Security.JWS; + +public static class JwsGenerator { + public static bool TryEncode(RSA rsa, Jwk jwk, JwsHeader protectedHeader, string? payload, [NotNullWhen(true)] out JwsMessage? jwsMessage, [NotNullWhen(false)] out string? errorMessage, string? keyId = null) { + try { + jwsMessage = Encode(rsa, jwk, protectedHeader, payload, keyId); + errorMessage = null; + return true; + } catch (Exception ex) { + jwsMessage = null; + errorMessage = ex.Message; + return false; + } + } + + public static bool TryEncode(RSA rsa, Jwk jwk, JwsHeader protectedHeader, byte[] payload, [NotNullWhen(true)] out JwsMessage? jwsMessage, [NotNullWhen(false)] out string? errorMessage, string? keyId = null) { + try { + jwsMessage = Encode(rsa, jwk, protectedHeader, payload, keyId); + errorMessage = null; + return true; + } catch (Exception ex) { + jwsMessage = null; + errorMessage = ex.Message; + return false; + } + } + + public static bool TryEncode(RSA rsa, Jwk jwk, JwsHeader protectedHeader, T payload, [NotNullWhen(true)] out JwsMessage? jwsMessage, [NotNullWhen(false)] out string? errorMessage, string? keyId = null) { + try { + jwsMessage = Encode(rsa, jwk, protectedHeader, payload, keyId); + errorMessage = null; + return true; + } catch (Exception ex) { + jwsMessage = null; + errorMessage = ex.Message; + return false; + } + } + + public static bool TryEncode(RSA rsa, Jwk jwk, JwsHeader protectedHeader, [NotNullWhen(true)] out JwsMessage? jwsMessage, [NotNullWhen(false)] out string? errorMessage, string? keyId = null) { + try { + jwsMessage = Encode(rsa, jwk, protectedHeader, keyId); + errorMessage = null; + return true; + } catch (Exception ex) { + jwsMessage = null; + errorMessage = ex.Message; + return false; + } + } + + public static bool TryGetKeyAuthorization(Jwk jwk, string token, [NotNullWhen(true)] out string? keyAuthorization, [NotNullWhen(false)] out string? errorMessage) { + keyAuthorization = null; + errorMessage = null; + if (!JwkGenerator.TryComputeThumbprint(jwk, out var thumb, out errorMessage)) { + return false; + } + keyAuthorization = $"{token}.{thumb}"; + return true; + } + + private static JwsMessage Encode(RSA rsa, Jwk jwk, JwsHeader protectedHeader, string? payload, string? keyId = null) { + return EncodeInternal(rsa, jwk, protectedHeader, payload, keyId); + } + + private static JwsMessage Encode(RSA rsa, Jwk jwk, JwsHeader protectedHeader, byte[] payload, string? keyId = null) { + string encodedPayload = Base64UrlUtility.Encode(payload); + return EncodeInternal(rsa, jwk, protectedHeader, encodedPayload, keyId, isPayloadEncoded: true); + } + + private static JwsMessage Encode(RSA rsa, Jwk jwk, JwsHeader protectedHeader, T payload, string? keyId = null) { + string encodedPayload = Base64UrlUtility.Encode(payload.ToJson()); + return EncodeInternal(rsa, jwk, protectedHeader, encodedPayload, keyId, isPayloadEncoded: true); + } + + private static JwsMessage Encode(RSA rsa, Jwk jwk, JwsHeader protectedHeader, string? keyId = null) { + // POST-as-GET: empty payload + return EncodeInternal(rsa, jwk, protectedHeader, null, keyId); + } + + private static JwsMessage EncodeInternal( + RSA rsa, + Jwk jwk, + JwsHeader protectedHeader, + string? payload, + string? keyId, + bool isPayloadEncoded = false) { + protectedHeader.Algorithm = JwkAlgorithm.Rs256.Name; + + if (!string.IsNullOrEmpty(keyId)) + protectedHeader.KeyId = keyId; + else + protectedHeader.Key = jwk; + + var message = new JwsMessage { + Payload = string.Empty, + Protected = Base64UrlUtility.Encode(protectedHeader.ToJson()) + }; + + if (payload != null) { + message.Payload = isPayloadEncoded ? payload : Base64UrlUtility.Encode(payload); + } + + var signingInput = Encoding.ASCII.GetBytes($"{message.Protected}.{message.Payload}"); + message.Signature = Base64UrlUtility.Encode( + rsa.SignData(signingInput, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)); + + return message; + } +} diff --git a/src/MaksIT.Core/Security/JWS/JwsHeader.cs b/src/MaksIT.Core/Security/JWS/JwsHeader.cs new file mode 100644 index 0000000..49c0091 --- /dev/null +++ b/src/MaksIT.Core/Security/JWS/JwsHeader.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Serialization; + + +namespace MaksIT.Core.Security.JWS; + +public class JwsHeader { + [JsonPropertyName("alg")] + public string? Algorithm { get; set; } + + [JsonPropertyName("kid")] + public string? KeyId { get; set; } + + [JsonPropertyName("jwk")] + public object? Key { get; set; } +} \ No newline at end of file diff --git a/src/MaksIT.Core/Security/JWS/JwsMessage.cs b/src/MaksIT.Core/Security/JWS/JwsMessage.cs new file mode 100644 index 0000000..070a67e --- /dev/null +++ b/src/MaksIT.Core/Security/JWS/JwsMessage.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +namespace MaksIT.Core.Security.JWS; + +public class JwsMessage { + [JsonPropertyName("protected")] + public string Protected { get; set; } = string.Empty; + + [JsonPropertyName("payload")] + public string Payload { get; set; } = string.Empty; + + [JsonPropertyName("signature")] + public string Signature { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/MaksIT.Core/Security/JWT/CustomClaims.cs b/src/MaksIT.Core/Security/JWT/CustomClaims.cs index 73b37d8..1b98863 100644 --- a/src/MaksIT.Core/Security/JWT/CustomClaims.cs +++ b/src/MaksIT.Core/Security/JWT/CustomClaims.cs @@ -1,12 +1,9 @@ using MaksIT.Core.Abstractions; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; + namespace MaksIT.Core.Security.JWT; public class CustomClaims : Enumeration { public static readonly CustomClaims AclEntry = new(1, "acl_entry"); + private CustomClaims(int id, string name) : base(id, name) { } } diff --git a/src/MaksIT.Core/Security/JWT/JwtGenerator.cs b/src/MaksIT.Core/Security/JWT/JwtGenerator.cs index 13e1bab..1f975b7 100644 --- a/src/MaksIT.Core/Security/JWT/JwtGenerator.cs +++ b/src/MaksIT.Core/Security/JWT/JwtGenerator.cs @@ -1,16 +1,13 @@ -using Microsoft.IdentityModel.Tokens; -using System.Diagnostics.CodeAnalysis; -using System.IdentityModel.Tokens.Jwt; +using System.Text; using System.Security.Claims; using System.Security.Cryptography; -using System.Text; +using System.IdentityModel.Tokens.Jwt; +using System.Diagnostics.CodeAnalysis; +using Microsoft.IdentityModel.Tokens; namespace MaksIT.Core.Security.JWT; - - - public static class JwtGenerator { /// /// Attempts to generate a JWT token using the specified request parameters.