(feature): jwk and jws handling
This commit is contained in:
parent
186e99a6f4
commit
e619cf276c
58
README.md
58
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).
|
||||
|
||||
107
src/MaksIT.Core.Tests/Security/JwkGeneratorTests.cs
Normal file
107
src/MaksIT.Core.Tests/Security/JwkGeneratorTests.cs
Normal file
@ -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<JwkCurve>().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<JwkCurve>().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);
|
||||
}
|
||||
}
|
||||
84
src/MaksIT.Core.Tests/Security/JwsGeneratorTests.cs
Normal file
84
src/MaksIT.Core.Tests/Security/JwsGeneratorTests.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,7 @@
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>MaksIT.Core</PackageId>
|
||||
<Version>1.5.3</Version>
|
||||
<Version>1.5.4</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Core</Product>
|
||||
|
||||
55
src/MaksIT.Core/Security/JWK/Base64UrlUtility.cs
Normal file
55
src/MaksIT.Core/Security/JWK/Base64UrlUtility.cs
Normal file
@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Text;
|
||||
|
||||
namespace MaksIT.Core.Security.JWK;
|
||||
|
||||
/// <summary>
|
||||
/// Provides RFC 4648-compliant Base64Url encoding and decoding utilities.
|
||||
/// </summary>
|
||||
public static class Base64UrlUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// Encodes a byte array to a Base64Url string (RFC 4648 §5).
|
||||
/// </summary>
|
||||
public static string Encode(byte[] data)
|
||||
{
|
||||
if (data == null) throw new ArgumentNullException(nameof(data));
|
||||
string base64 = Convert.ToBase64String(data);
|
||||
return base64.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a UTF-8 string to a Base64Url string (RFC 4648 §5).
|
||||
/// </summary>
|
||||
public static string Encode(string value)
|
||||
{
|
||||
if (value == null) throw new ArgumentNullException(nameof(value));
|
||||
return Encode(Encoding.UTF8.GetBytes(value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a Base64Url string to a byte array.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a Base64Url string to a UTF-8 string.
|
||||
/// </summary>
|
||||
public static string DecodeToString(string base64Url)
|
||||
{
|
||||
return Encoding.UTF8.GetString(Decode(base64Url));
|
||||
}
|
||||
}
|
||||
73
src/MaksIT.Core/Security/JWK/Jwk.cs
Normal file
73
src/MaksIT.Core/Security/JWK/Jwk.cs
Normal file
@ -0,0 +1,73 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Security.JWK;
|
||||
|
||||
/// <summary>
|
||||
/// Standard JWK class supporting RSA, EC, and octet keys.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
17
src/MaksIT.Core/Security/JWK/JwkAlgorithm.cs
Normal file
17
src/MaksIT.Core/Security/JWK/JwkAlgorithm.cs
Normal file
@ -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) { }
|
||||
}
|
||||
12
src/MaksIT.Core/Security/JWK/JwkCurve.cs
Normal file
12
src/MaksIT.Core/Security/JWK/JwkCurve.cs
Normal file
@ -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) { }
|
||||
}
|
||||
213
src/MaksIT.Core/Security/JWK/JwkGenerator.cs
Normal file
213
src/MaksIT.Core/Security/JWK/JwkGenerator.cs
Normal file
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Provides utilities for JWK (JSON Web Key) operations, including RFC 7638 thumbprint computation and key generation.
|
||||
/// </summary>
|
||||
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!;
|
||||
}
|
||||
}
|
||||
12
src/MaksIT.Core/Security/JWK/JwkKeyType.cs
Normal file
12
src/MaksIT.Core/Security/JWK/JwkKeyType.cs
Normal file
@ -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) { }
|
||||
}
|
||||
117
src/MaksIT.Core/Security/JWS/JwsGenerator.cs
Normal file
117
src/MaksIT.Core/Security/JWS/JwsGenerator.cs
Normal file
@ -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<T>(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<T>(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;
|
||||
}
|
||||
}
|
||||
15
src/MaksIT.Core/Security/JWS/JwsHeader.cs
Normal file
15
src/MaksIT.Core/Security/JWS/JwsHeader.cs
Normal file
@ -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; }
|
||||
}
|
||||
14
src/MaksIT.Core/Security/JWS/JwsMessage.cs
Normal file
14
src/MaksIT.Core/Security/JWS/JwsMessage.cs
Normal file
@ -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;
|
||||
}
|
||||
@ -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) { }
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
/// <summary>
|
||||
/// Attempts to generate a JWT token using the specified request parameters.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user