Compare commits
No commits in common. "e2f930587a8127d1a5dd3b935876d9ccb83b0cd6" and "e619cf276c7a2606ced2f885a3588fb0965f5d2b" have entirely different histories.
e2f930587a
...
e619cf276c
237
README.md
237
README.md
@ -28,7 +28,6 @@
|
||||
- [Password Hasher](#password-hasher)
|
||||
- [JWT Generator](#jwt-generator)
|
||||
- [JWK Generator](#jwk-generator)
|
||||
- [JWK Thumbprint Utility](#jwk-thumbprint-utility)
|
||||
- [JWS Generator](#jws-generator)
|
||||
- [TOTP Generator](#totp-generator)
|
||||
- [Web API Models](#web-api-models)
|
||||
@ -969,215 +968,85 @@ JwtGenerator.TryGenerateToken(secret, issuer, audience, 60, "user", roles, out v
|
||||
|
||||
### JWK Generator
|
||||
|
||||
The `JwkGenerator` class in the `MaksIT.Core.Security.JWK` namespace provides a utility method for generating a minimal RSA public JWK (JSON Web Key) from a given `RSA` instance.
|
||||
|
||||
---
|
||||
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
|
||||
|
||||
1. **Generate RSA Public JWK**:
|
||||
- Extracts the RSA public exponent and modulus from an `RSA` object and encodes them as a JWK.
|
||||
|
||||
---
|
||||
- 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
|
||||
using System.Security.Cryptography;
|
||||
using MaksIT.Core.Security.JWK;
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
var result = JwkGenerator.TryGenerateFromRCA(rsa, out var jwk, out var errorMessage);
|
||||
if (result)
|
||||
{
|
||||
// jwk contains KeyType, RsaExponent, RsaModulus
|
||||
Console.WriteLine($"Exponent: {jwk!.RsaExponent}, Modulus: {jwk.RsaModulus}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Error: {errorMessage}");
|
||||
}
|
||||
// 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);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### API
|
||||
|
||||
```csharp
|
||||
public static bool TryGenerateFromRCA(
|
||||
RSA rsa,
|
||||
out Jwk? jwk,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
)
|
||||
```
|
||||
- `rsa`: The RSA instance to extract public parameters from.
|
||||
- `jwk`: The resulting JWK object (with `KeyType`, `RsaExponent`, and `RsaModulus`).
|
||||
- `errorMessage`: Error message if generation fails.
|
||||
|
||||
---
|
||||
|
||||
#### Notes
|
||||
- Only supports RSA public keys.
|
||||
- The generated JWK includes only the public exponent and modulus.
|
||||
- Returns `false` and an error message if the RSA parameters are missing or invalid.
|
||||
|
||||
---
|
||||
|
||||
### JWS Generator
|
||||
|
||||
The `JwsGenerator` class in the `MaksIT.Core.Security.JWS` namespace provides methods for creating JSON Web Signatures (JWS) using RSA keys and JWKs. It supports signing string or object payloads and produces JWS objects with protected headers, payload, and signature.
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **JWS Creation**:
|
||||
- Sign string or object payloads using an RSA key and JWK.
|
||||
- Produces a JWS message containing the protected header, payload, and signature.
|
||||
1. **TOTP Generation**:
|
||||
- Generate TOTPs based on shared secrets.
|
||||
|
||||
2. **TOTP Validation**:
|
||||
- Validate TOTPs with time tolerance.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
##### Generating a TOTP
|
||||
```csharp
|
||||
using System.Security.Cryptography;
|
||||
using MaksIT.Core.Security.JWK;
|
||||
using MaksIT.Core.Security.JWS;
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
JwkGenerator.TryGenerateFromRCA(rsa, out var jwk, out var errorMessage);
|
||||
var header = new JwsHeader();
|
||||
var payload = "my-payload";
|
||||
var result = JwsGenerator.TryEncode(rsa, jwk!, header, payload, out var jwsMessage, out var error);
|
||||
if (result)
|
||||
{
|
||||
Console.WriteLine($"Signature: {jwsMessage!.Signature}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Error: {error}");
|
||||
}
|
||||
TotpGenerator.TryGenerate(secret, TotpGenerator.GetCurrentTimeStepNumber(), out var totp, out var error);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### API
|
||||
|
||||
```csharp
|
||||
public static bool TryEncode(
|
||||
RSA rsa,
|
||||
Jwk jwk,
|
||||
JwsHeader protectedHeader,
|
||||
out JwsMessage? message,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
)
|
||||
```
|
||||
- Signs an empty payload.
|
||||
|
||||
```csharp
|
||||
public static bool TryEncode<T>(
|
||||
RSA rsa,
|
||||
Jwk jwk,
|
||||
JwsHeader protectedHeader,
|
||||
T? payload,
|
||||
out JwsMessage? message,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
)
|
||||
```
|
||||
- Signs the provided payload (string or object).
|
||||
|
||||
---
|
||||
|
||||
#### Notes
|
||||
- Only supports signing (no verification or key authorization).
|
||||
- The protected header is automatically set to use RS256.
|
||||
- The payload is base64url encoded.
|
||||
- Returns `false` and an error message if signing fails.
|
||||
|
||||
---
|
||||
|
||||
### JWK Thumbprint Utility
|
||||
|
||||
The `JwkThumbprintUtility` class in the `MaksIT.Core.Security.JWK` namespace provides methods for computing RFC7638 JWK SHA-256 thumbprints and generating key authorization strings for ACME challenges.
|
||||
|
||||
---
|
||||
|
||||
#### Features
|
||||
|
||||
1. **JWK SHA-256 Thumbprint**:
|
||||
- Computes the RFC7638-compliant SHA-256 thumbprint of a JWK (Base64Url encoded).
|
||||
2. **ACME Key Authorization**:
|
||||
- Generates the key authorization string for ACME/Let's Encrypt HTTP challenges.
|
||||
|
||||
---
|
||||
|
||||
#### Example Usage
|
||||
|
||||
##### Computing a JWK Thumbprint
|
||||
```csharp
|
||||
using System.Security.Cryptography;
|
||||
using MaksIT.Core.Security.JWK;
|
||||
|
||||
using var rsa = RSA.Create(2048);
|
||||
JwkGenerator.TryGenerateFromRCA(rsa, out var jwk, out var errorMessage);
|
||||
var result = JwkThumbprintUtility.TryGetSha256Thumbprint(jwk!, out var thumbprint, out var error);
|
||||
if (result)
|
||||
{
|
||||
Console.WriteLine($"Thumbprint: {thumbprint}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Error: {error}");
|
||||
}
|
||||
```
|
||||
|
||||
##### Generating ACME Key Authorization
|
||||
```csharp
|
||||
var token = "acme-token";
|
||||
var result = JwkThumbprintUtility.TryGetKeyAuthorization(jwk!, token, out var keyAuth, out var error);
|
||||
if (result)
|
||||
{
|
||||
Console.WriteLine($"Key Authorization: {keyAuth}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"Error: {error}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### API
|
||||
|
||||
```csharp
|
||||
public static bool TryGetSha256Thumbprint(
|
||||
Jwk jwk,
|
||||
out string? thumbprint,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
)
|
||||
```
|
||||
- Computes the RFC7638 SHA-256 thumbprint of the JWK.
|
||||
|
||||
```csharp
|
||||
public static bool TryGetKeyAuthorization(
|
||||
Jwk jwk,
|
||||
string token,
|
||||
out string? keyAuthorization,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
)
|
||||
```
|
||||
- Generates the ACME key authorization string: `{token}.{thumbprint}`.
|
||||
|
||||
---
|
||||
|
||||
#### Notes
|
||||
- Only supports RSA JWKs (requires exponent and modulus).
|
||||
- Returns `false` and an error message if required JWK fields are missing or invalid.
|
||||
- Thumbprint is Base64Url encoded and suitable for ACME/Let's Encrypt HTTP challenges.
|
||||
|
||||
---
|
||||
|
||||
## Others
|
||||
|
||||
### Culture
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using MaksIT.Core.Security.JWK;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Tests.Security.JWK;
|
||||
|
||||
public class JwkGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGenerateFromRCA_ValidRsa_ReturnsTrueAndJwk()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var result = JwkGenerator.TryGenerateFromRCA(rsa, out var jwk, out var errorMessage);
|
||||
Assert.True(result);
|
||||
Assert.NotNull(jwk);
|
||||
Assert.Null(errorMessage);
|
||||
Assert.Equal(JwkKeyType.Rsa.Name, jwk!.KeyType);
|
||||
Assert.False(string.IsNullOrEmpty(jwk.RsaExponent));
|
||||
Assert.False(string.IsNullOrEmpty(jwk.RsaModulus));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGenerateFromRCA_MissingExponentOrModulus_ReturnsFalseAndError()
|
||||
{
|
||||
using var rsa = RSA.Create();
|
||||
// ExportParameters returns valid values, so we simulate missing exponent/modulus by mocking
|
||||
// Instead, test with a custom RSA implementation that throws
|
||||
var fakeRsa = new FakeRsaMissingParams();
|
||||
var result = JwkGenerator.TryGenerateFromRCA(fakeRsa, out var jwk, out var errorMessage);
|
||||
Assert.False(result);
|
||||
Assert.Null(jwk);
|
||||
Assert.Contains("missing exponent or modulus", errorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGenerateFromRCA_ExportParametersThrows_ReturnsFalseAndError()
|
||||
{
|
||||
var fakeRsa = new FakeRsaThrows();
|
||||
var result = JwkGenerator.TryGenerateFromRCA(fakeRsa, out var jwk, out var errorMessage);
|
||||
Assert.False(result);
|
||||
Assert.Null(jwk);
|
||||
Assert.Contains("ExportParameters failed", errorMessage);
|
||||
}
|
||||
|
||||
private class FakeRsaMissingParams : RSA
|
||||
{
|
||||
public override RSAParameters ExportParameters(bool includePrivateParameters)
|
||||
=> new RSAParameters { Exponent = null, Modulus = null };
|
||||
// ...other abstract members throw NotImplementedException
|
||||
public override byte[] Decrypt(byte[] data, RSAEncryptionPadding padding) => throw new NotImplementedException();
|
||||
public override byte[] Encrypt(byte[] data, RSAEncryptionPadding padding) => throw new NotImplementedException();
|
||||
public override byte[] SignHash(byte[] hash, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) => throw new NotImplementedException();
|
||||
public override bool VerifyHash(byte[] hash, byte[] signature, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) => throw new NotImplementedException();
|
||||
public override void ImportParameters(RSAParameters parameters) => throw new NotImplementedException();
|
||||
protected override void Dispose(bool disposing) { }
|
||||
}
|
||||
|
||||
private class FakeRsaThrows : RSA
|
||||
{
|
||||
public override RSAParameters ExportParameters(bool includePrivateParameters)
|
||||
=> throw new Exception("ExportParameters failed");
|
||||
// ...other abstract members throw NotImplementedException
|
||||
public override byte[] Decrypt(byte[] data, RSAEncryptionPadding padding) => throw new NotImplementedException();
|
||||
public override byte[] Encrypt(byte[] data, RSAEncryptionPadding padding) => throw new NotImplementedException();
|
||||
public override byte[] SignHash(byte[] hash, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) => throw new NotImplementedException();
|
||||
public override bool VerifyHash(byte[] hash, byte[] signature, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) => throw new NotImplementedException();
|
||||
public override void ImportParameters(RSAParameters parameters) => throw new NotImplementedException();
|
||||
protected override void Dispose(bool disposing) { }
|
||||
}
|
||||
}
|
||||
@ -1,63 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using MaksIT.Core.Security;
|
||||
using MaksIT.Core.Security.JWK;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Tests.Security.JWK;
|
||||
|
||||
public class JwkThumbprintUtilityTests
|
||||
{
|
||||
[Fact]
|
||||
public void TryGetSha256Thumbprint_ValidRsaJwk_ReturnsTrueAndThumbprint()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var genResult = JwkGenerator.TryGenerateFromRCA(rsa, out var jwk, out var genError);
|
||||
Assert.True(genResult);
|
||||
Assert.NotNull(jwk);
|
||||
var result = JwkThumbprintUtility.TryGetSha256Thumbprint(jwk!, out var thumbprint, out var error);
|
||||
Assert.True(result);
|
||||
Assert.False(string.IsNullOrEmpty(thumbprint));
|
||||
Assert.Null(error);
|
||||
// Should be base64url encoded and of expected length (SHA256 =32 bytes)
|
||||
var decoded = Base64UrlUtility.Decode(thumbprint!);
|
||||
Assert.Equal(32, decoded.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetSha256Thumbprint_NullExponentOrModulus_ReturnsFalseAndError()
|
||||
{
|
||||
var jwk = new Jwk { RsaExponent = null, RsaModulus = null };
|
||||
var result = JwkThumbprintUtility.TryGetSha256Thumbprint(jwk, out var thumbprint, out var error);
|
||||
Assert.False(result);
|
||||
Assert.Null(thumbprint);
|
||||
Assert.Contains("exponent or modulus", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetKeyAuthorization_ValidJwk_ReturnsTrueAndKeyAuthorization()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var genResult = JwkGenerator.TryGenerateFromRCA(rsa, out var jwk, out var genError);
|
||||
Assert.True(genResult);
|
||||
Assert.NotNull(jwk);
|
||||
var token = "test-token";
|
||||
var result = JwkThumbprintUtility.TryGetKeyAuthorization(jwk!, token, out var keyAuth, out var error);
|
||||
Assert.True(result);
|
||||
Assert.Null(error);
|
||||
Assert.StartsWith(token + ".", keyAuth);
|
||||
var parts = keyAuth!.Split('.');
|
||||
Assert.Equal(2, parts.Length);
|
||||
Assert.False(string.IsNullOrEmpty(parts[1]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetKeyAuthorization_NullExponentOrModulus_ReturnsFalseAndError()
|
||||
{
|
||||
var jwk = new Jwk { RsaExponent = null, RsaModulus = null };
|
||||
var token = "test-token";
|
||||
var result = JwkThumbprintUtility.TryGetKeyAuthorization(jwk, token, out var keyAuth, out var error);
|
||||
Assert.False(result);
|
||||
Assert.Null(keyAuth);
|
||||
Assert.Contains("Failed to compute thumbprint", error);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
using System.Security.Cryptography;
|
||||
using MaksIT.Core.Security;
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using MaksIT.Core.Security.JWK;
|
||||
using MaksIT.Core.Security.JWS;
|
||||
|
||||
@ -7,91 +7,78 @@ using MaksIT.Core.Security.JWS;
|
||||
namespace MaksIT.Core.Tests.Security;
|
||||
|
||||
public class JwsGeneratorTests {
|
||||
[Fact]
|
||||
public void TryEncode_ValidRsaAndJwk_ReturnsTrueAndMessage()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var jwkResult = JwkGenerator.TryGenerateFromRCA(rsa, out var jwk, out var jwkError);
|
||||
Assert.True(jwkResult);
|
||||
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);
|
||||
var header = new JwsHeader();
|
||||
var result = JwsGenerator.TryEncode(rsa, jwk!, header, out var message, out var error);
|
||||
Assert.True(result);
|
||||
Assert.NotNull(message);
|
||||
Assert.Null(error);
|
||||
Assert.False(string.IsNullOrEmpty(message!.Protected));
|
||||
Assert.False(string.IsNullOrEmpty(message.Signature));
|
||||
return (rsa, jwk);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryEncode_WithPayload_ReturnsEncodedPayload()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var jwkResult = JwkGenerator.TryGenerateFromRCA(rsa, out var jwk, out var jwkError);
|
||||
Assert.True(jwkResult);
|
||||
Assert.NotNull(jwk);
|
||||
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 message, out var error);
|
||||
Assert.True(result);
|
||||
Assert.NotNull(message);
|
||||
Assert.Null(error);
|
||||
Assert.False(string.IsNullOrEmpty(message!.Payload));
|
||||
// Decoded payload should match
|
||||
var decoded = Base64UrlUtility.DecodeToString(message.Payload);
|
||||
Assert.Equal(payload, decoded);
|
||||
|
||||
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 TryEncode_InvalidRsa_ReturnsFalseAndError()
|
||||
{
|
||||
var fakeRsa = new FakeRsaThrows();
|
||||
var jwk = new Jwk { KeyType = JwkKeyType.Rsa.Name };
|
||||
public void Encode_WithByteArrayPayload_ProducesValidJws() {
|
||||
var (rsa, jwk) = GenerateRsaAndJwk();
|
||||
var header = new JwsHeader();
|
||||
var result = JwsGenerator.TryEncode(fakeRsa, jwk, header, out var message, out var error);
|
||||
Assert.False(result);
|
||||
Assert.Null(message);
|
||||
Assert.NotNull(error);
|
||||
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 TryEncode_JwkWithKeyId_SetsHeaderKid()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var jwk = new Jwk { KeyType = JwkKeyType.Rsa.Name, KeyId = "my-key-id" };
|
||||
public void Encode_WithGenericPayload_ProducesValidJws() {
|
||||
var (rsa, jwk) = GenerateRsaAndJwk();
|
||||
var header = new JwsHeader();
|
||||
var result = JwsGenerator.TryEncode(rsa, jwk, header, out var message, out var error);
|
||||
Assert.True(result);
|
||||
Assert.NotNull(message);
|
||||
Assert.Null(error);
|
||||
// Decode protected header
|
||||
var protectedJson = Base64UrlUtility.DecodeToString(message!.Protected);
|
||||
Assert.Contains("my-key-id", protectedJson);
|
||||
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 TryEncode_JwkWithoutKeyId_SetsHeaderJwk()
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
var jwk = new Jwk { KeyType = JwkKeyType.Rsa.Name };
|
||||
public void Encode_PostAsGet_ProducesValidJws() {
|
||||
var (rsa, jwk) = GenerateRsaAndJwk();
|
||||
var header = new JwsHeader();
|
||||
var result = JwsGenerator.TryEncode(rsa, jwk, header, out var message, out var error);
|
||||
Assert.True(result);
|
||||
Assert.NotNull(message);
|
||||
Assert.Null(error);
|
||||
var protectedJson = Base64UrlUtility.DecodeToString(message!.Protected);
|
||||
Assert.Contains("jwk", protectedJson);
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
private class FakeRsaThrows : RSA
|
||||
{
|
||||
public override RSAParameters ExportParameters(bool includePrivateParameters)
|
||||
=> throw new Exception("ExportParameters failed");
|
||||
public override byte[] Decrypt(byte[] data, RSAEncryptionPadding padding) => throw new NotImplementedException();
|
||||
public override byte[] Encrypt(byte[] data, RSAEncryptionPadding padding) => throw new NotImplementedException();
|
||||
public override byte[] SignHash(byte[] hash, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) => throw new Exception("SignData failed");
|
||||
public override bool VerifyHash(byte[] hash, byte[] signature, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding) => throw new NotImplementedException();
|
||||
public override void ImportParameters(RSAParameters parameters) => throw new NotImplementedException();
|
||||
protected override void Dispose(bool disposing) { }
|
||||
[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.7</Version>
|
||||
<Version>1.5.4</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Core</Product>
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
using System.Text;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Provides RFC 4648-compliant Base64Url encoding and decoding utilities.
|
||||
/// </summary>
|
||||
public static class Base64UrlUtility {
|
||||
|
||||
/// <summary>
|
||||
/// Encodes a UTF-8 string to a Base64Url string (RFC 4648 §5).
|
||||
/// https://tools.ietf.org/html/rfc4648#section-5
|
||||
/// </summary>
|
||||
public static string Encode(string value) =>
|
||||
Encode(Encoding.UTF8.GetBytes(value));
|
||||
|
||||
/// <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); // Regular base64 encoder
|
||||
|
||||
return base64.TrimEnd('=') // Remove any trailing '='s
|
||||
.Replace('+', '-') // 62nd char of encoding
|
||||
.Replace('/', '_'); // 63rd char of encoding
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a Base64Url string to a UTF-8 string.
|
||||
/// </summary>
|
||||
public static string DecodeToString(string base64Url) =>
|
||||
Encoding.UTF8.GetString(Decode(base64Url));
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
@ -3,172 +3,71 @@
|
||||
|
||||
namespace MaksIT.Core.Security.JWK;
|
||||
|
||||
|
||||
public class Jwk {
|
||||
#region Common fields
|
||||
/// <summary>
|
||||
/// "kty" (Key Type) Parameter
|
||||
/// <para>
|
||||
/// The "kty" (key type) parameter identifies the cryptographic algorithm
|
||||
/// family used with the key, such as "RSA" or "EC".
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("kty")]
|
||||
public string? KeyType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// "kid" (Key ID) Parameter
|
||||
/// <para>
|
||||
/// The "kid" (key ID) parameter is used to match a specific key. This
|
||||
/// is used, for instance, to choose among a set of keys within a JWK Set
|
||||
/// during key rollover. The structure of the "kid" value is
|
||||
/// unspecified.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("kid")]
|
||||
public string? KeyId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// "alg" (Algorithm) Parameter
|
||||
/// <para>
|
||||
/// The "alg" (algorithm) parameter identifies the algorithm intended for
|
||||
/// use with the key.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("alg")]
|
||||
public string? Algorithm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// "use" (Public Key Use) Parameter
|
||||
/// <para>
|
||||
/// The "use" (public key use) parameter identifies the intended use of
|
||||
/// the public key. The "use" parameter is employed to indicate whether
|
||||
/// a public key is used for encrypting data or verifying the signature
|
||||
/// on data.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("use")]
|
||||
public string? KeyUse { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// "key_ops" (Key Operations) Parameter
|
||||
/// <para>
|
||||
/// The "key_ops" (key operations) parameter identifies the operation(s) for which the key is intended to be used.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("key_ops")]
|
||||
public string[]? KeyOperations { get; set; }
|
||||
#endregion
|
||||
|
||||
#region RSA fields
|
||||
/// <summary>
|
||||
/// The modulus value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("n")]
|
||||
public string? RsaModulus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The exponent value for the public RSA key. It is represented as the Base64URL encoding of value's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("e")]
|
||||
public string? RsaExponent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The first prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("p")]
|
||||
public string? RsaFirstPrimeFactor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The second prime factor. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("q")]
|
||||
public string? RsaSecondPrimeFactor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The first factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dp")]
|
||||
public string? RsaFirstFactorCRTExponent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The second factor Chinese Remainder Theorem exponent. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("dq")]
|
||||
public string? RsaSecondFactorCRTExponent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The first Chinese Remainder Theorem coefficient. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("qi")]
|
||||
public string? RsaFirstCRTCoefficient { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The other primes information, should they exist, null or an empty list if not specified.
|
||||
/// </summary>
|
||||
[JsonPropertyName("oth")]
|
||||
public List<OtherPrimeInfo>? RsaOtherPrimesInfo { get; set; }
|
||||
#endregion
|
||||
|
||||
#region EC fields
|
||||
/// <summary>
|
||||
/// The "crv" (Curve) parameter identifies the cryptographic curve used with the key.
|
||||
/// </summary>
|
||||
[JsonPropertyName("crv")]
|
||||
public string? EcCurve { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The "x" coordinate for the EC public key. It is represented as the Base64URL encoding of the coordinate's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("x")]
|
||||
public string? EcX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The "y" coordinate for the EC public key. It is represented as the Base64URL encoding of the coordinate's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("y")]
|
||||
public string? EcY { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Private Key field
|
||||
/// <summary>
|
||||
/// The private key value ("d"). Used for RSA (private exponent) and EC (private key).
|
||||
/// RFC 7518 uses "d" for both.
|
||||
/// </summary>
|
||||
[JsonPropertyName("d")]
|
||||
public string? PrivateKey { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Symmetric (octet) fields
|
||||
/// <summary>
|
||||
/// The symmetric (octet) key value. It is represented as the Base64URL encoding of the value's big endian representation.
|
||||
/// </summary>
|
||||
[JsonPropertyName("k")]
|
||||
public string? SymmetricKey { get; set; }
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an entry in the 'oth' (Other Primes Info) parameter for multi-prime RSA keys.
|
||||
/// Standard JWK class supporting RSA, EC, and octet keys.
|
||||
/// </summary>
|
||||
public class OtherPrimeInfo {
|
||||
#region OtherPrimeInfo fields
|
||||
/// <summary>
|
||||
/// The value of the other prime factor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("r")]
|
||||
public string? PrimeFactor { get; set; }
|
||||
public class Jwk {
|
||||
// Common fields
|
||||
[JsonPropertyName("kty")]
|
||||
public string? Kty { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The CRT exponent of the other prime factor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("d")]
|
||||
public string? FactorCRTExponent { get; set; }
|
||||
[JsonPropertyName("kid")]
|
||||
public string? Kid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The CRT coefficient of the other prime factor.
|
||||
/// </summary>
|
||||
[JsonPropertyName("t")]
|
||||
public string? FactorCRTCoefficient { get; set; }
|
||||
#endregion
|
||||
[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; }
|
||||
}
|
||||
@ -1,39 +1,213 @@
|
||||
using System.Security.Cryptography;
|
||||
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 RFC7638 thumbprint computation and key generation.
|
||||
/// Provides utilities for JWK (JSON Web Key) operations, including RFC 7638 thumbprint computation and key generation.
|
||||
/// </summary>
|
||||
public static class JwkGenerator {
|
||||
public static bool TryGenerateFromRCA(
|
||||
RSA rsa,
|
||||
out Jwk? jwk,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) {
|
||||
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 {
|
||||
var publicParameters = rsa.ExportParameters(false);
|
||||
|
||||
var exp = publicParameters.Exponent;
|
||||
var mod = publicParameters.Modulus;
|
||||
|
||||
if (exp == null || mod == null)
|
||||
throw new ArgumentException("RSA parameters are missing exponent or modulus.");
|
||||
|
||||
jwk = new Jwk {
|
||||
KeyType = JwkKeyType.Rsa.Name,
|
||||
RsaExponent = Base64UrlUtility.Encode(exp),
|
||||
RsaModulus = Base64UrlUtility.Encode(mod),
|
||||
};
|
||||
jwk = GenerateRsa(keySize, includePrivate, alg, use, keyOps);
|
||||
errorMessage = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
} 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!;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Core.Security.JWK;
|
||||
|
||||
public static class JwkThumbprintUtility {
|
||||
/// <summary>
|
||||
/// Returns the key authorization string for ACME challenges.
|
||||
/// </summary>
|
||||
public static bool TryGetKeyAuthorization(
|
||||
Jwk jwk,
|
||||
string token,
|
||||
out string? keyAuthorization,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) {
|
||||
keyAuthorization = null;
|
||||
errorMessage = null;
|
||||
if (!TryGetSha256Thumbprint(jwk, out var thumbprint, out var thumbprintError)) {
|
||||
errorMessage = $"Failed to compute thumbprint: {thumbprintError}";
|
||||
return false;
|
||||
}
|
||||
keyAuthorization = $"{token}.{thumbprint}";
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the RFC7638 JWK SHA-256 thumbprint (Base64Url encoded).
|
||||
/// For thumbprint calculation, always build the JSON string manually or use OrderedJwk for correct property order.
|
||||
/// </summary>
|
||||
public static bool TryGetSha256Thumbprint(
|
||||
Jwk jwk,
|
||||
out string? thumbprint,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) {
|
||||
thumbprint = null;
|
||||
errorMessage = null;
|
||||
try {
|
||||
if (jwk.RsaExponent == null || jwk.RsaModulus == null)
|
||||
throw new ArgumentException("RSA exponent or modulus is null.");
|
||||
var thumbprintObj = new OrderedJwk {
|
||||
E = jwk.RsaExponent,
|
||||
Kty = JwkKeyType.Rsa.Name,
|
||||
N = jwk.RsaModulus
|
||||
};
|
||||
var json = thumbprintObj.ToJson();
|
||||
thumbprint = Base64UrlUtility.Encode(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
|
||||
return true;
|
||||
} catch (Exception ex) {
|
||||
errorMessage = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MaksIT.Core.Security.JWK;
|
||||
public 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!;
|
||||
}
|
||||
@ -8,60 +8,110 @@ using MaksIT.Core.Extensions;
|
||||
namespace MaksIT.Core.Security.JWS;
|
||||
|
||||
public static class JwsGenerator {
|
||||
public static bool TryEncode(
|
||||
RSA rsa,
|
||||
Jwk jwk,
|
||||
JwsHeader protectedHeader,
|
||||
out JwsMessage? message,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) => TryEncode<string>(rsa, jwk, protectedHeader, null, out message, out errorMessage);
|
||||
|
||||
|
||||
public static bool TryEncode<T>(
|
||||
RSA rsa,
|
||||
Jwk jwk,
|
||||
JwsHeader protectedHeader,
|
||||
T? payload,
|
||||
out JwsMessage? message,
|
||||
[NotNullWhen(false)] out string? errorMessage
|
||||
) {
|
||||
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 {
|
||||
protectedHeader.Algorithm = JwkAlgorithm.Rs256.Name;
|
||||
|
||||
if (jwk.KeyId != null)
|
||||
protectedHeader.KeyId = jwk.KeyId;
|
||||
else
|
||||
protectedHeader.Key = jwk;
|
||||
|
||||
var msg = new JwsMessage {
|
||||
Payload = "",
|
||||
Protected = Base64UrlUtility.Encode(protectedHeader.ToJson())
|
||||
};
|
||||
|
||||
if (payload != null) {
|
||||
if (payload is string stringPayload)
|
||||
msg.Payload = Base64UrlUtility.Encode(stringPayload);
|
||||
else
|
||||
msg.Payload = Base64UrlUtility.Encode(payload.ToJson());
|
||||
}
|
||||
|
||||
var signature = rsa.SignData(
|
||||
Encoding.ASCII.GetBytes($"{msg.Protected}.{msg.Payload}"),
|
||||
HashAlgorithmName.SHA256,
|
||||
RSASignaturePadding.Pkcs1
|
||||
);
|
||||
|
||||
msg.Signature = Base64UrlUtility.Encode(signature);
|
||||
|
||||
message = msg;
|
||||
jwsMessage = Encode(rsa, jwk, protectedHeader, payload, keyId);
|
||||
errorMessage = null;
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
message = null;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user