Still working on getting client credential flow complete, most of the request works but still working on generating the JWT.

This commit is contained in:
eelke 2026-03-16 19:15:04 +01:00
parent 1a8c63808a
commit 8782ef39c6
80 changed files with 1331 additions and 414 deletions

View file

@ -1,9 +1,22 @@
using System.Text;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Contracts;
public interface IDataEncryptionService
{
EncryptedValue Encrypt(ReadOnlySpan<byte> plain);
byte[] Decrypt(EncryptedValue input);
EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan<byte> plain);
byte[] Decrypt(IReadOnlyList<RealmDek> deks, EncryptedValue input);
}
public static class DataEncryptionServiceExtensions
{
public static string DecryptUtf8ToString(
this IDataEncryptionService des,
IReadOnlyList<RealmDek> deks,
EncryptedValue input)
{
return Encoding.UTF8.GetString(des.Decrypt(deks, input));
}
}

View file

@ -1,12 +1,10 @@
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Contracts;
public record CreateKeyResponse(KeyType KeyType, KeyData Key);
public interface IKeyService
{
RealmKey CreateKey(KeyPolicy policy);
JsonWebKey? CreateJsonWebKey(RealmKey realmKey);
CreateKeyResponse CreateKey(KeyPolicy policy);
}

View file

@ -1,6 +1,5 @@
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Core.Contracts;
@ -9,7 +8,7 @@ public interface IRealmService
Task<Realm?> FindById(Guid id, CancellationToken ct = default);
Task<Realm?> FindBySlug(string slug, CancellationToken ct = default);
Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default);
Task<Result<Realm>> Create(RealmCreateRequest request, CancellationToken ct = default);
Task LoadActiveKeys(Realm realm);
Task LoadDeks(Realm realm);
}

View file

@ -1,10 +1,10 @@
namespace IdentityShroud.Core.Contracts;
public class ClientCreateRequest
{
public required string ClientId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public string? SignatureAlgorithm { get; set; }
public bool? AllowClientCredentialsFlow { get; set; }
}
public record ClientCreateRequest(
string ClientId,
string? Name = null,
string? Description = null,
string? SignatureAlgorithm = null,
bool Confidential = false,
bool AllowClientCredentialsFlow = false,
bool GenerateSecret = false);

View file

@ -1,5 +1,6 @@
using System.Text.Json.Serialization;
using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Messages;
@ -9,7 +10,7 @@ namespace IdentityShroud.Core.Messages;
public class JsonWebKey
{
[JsonPropertyName("kty")]
public string KeyType { get; set; } = "RSA";
public required KeyType KeyType { get; set; }
// Common values sig(nature) enc(ryption)
[JsonPropertyName("use")]

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.DTO.OpenId;
public enum GrantTypes
{
[JsonStringEnumMemberName("client_credentials")]
ClientCredentials
}

View file

@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Services.OpenId;
public class TokenResponse
{
[JsonPropertyName("access_token")]
public required string AccessToken { get; set; }
[JsonPropertyName("token_type")]
public required string TokenType { get; set; }
[JsonPropertyName("expires_in")]
public int? ExpiresIn { get; set; }
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; set; }
}

View file

@ -1,3 +1,3 @@
namespace IdentityShroud.Core.Messages.Realm;
public record RealmCreateRequest(Guid? Id, string? Slug, string Name);
public record RealmCreateRequest(Guid? Id = null, string? Slug = null, string? Name = null);

View file

@ -1,12 +1,6 @@
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core;
namespace IdentityShroud.Core.EFCore;
public class DekIdConverter : ValueConverter<DekId, Guid>
{
public DekIdConverter()
: base(id => id.Id, guid => new DekId(guid))
{
}
}
public class DekIdConverter() : ValueConverter<DekId, Guid>(id => id.Id, guid => new DekId(guid));

View file

@ -0,0 +1,14 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class DictionaryToJsonConverter<TKey, TValue> : ValueConverter<Dictionary<TKey, TValue>, string>
where TKey : notnull
{
public DictionaryToJsonConverter() : base(
v => JsonSerializer.Serialize(v),
v => JsonSerializer.Deserialize<Dictionary<TKey, TValue>>(v) ?? new())
{
}
}

View file

@ -0,0 +1,5 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class JwtSigAlgNameConverter() : ValueConverter<JwtSigAlgName, string>(j => j.ToString(), s => new(s));

View file

@ -1,7 +1,7 @@
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core;
namespace IdentityShroud.Core.EFCore;
public class KekIdConverter : ValueConverter<KekId, Guid>
{

View file

@ -0,0 +1,6 @@
using IdentityShroud.Core.Security.Keys;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class KeyTypeConverter() : ValueConverter<KeyType, string>(id => id.ToString(), s => new(s));

View file

@ -0,0 +1,13 @@
using IdentityShroud.Core.Model;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace IdentityShroud.Core.EFCore;
public class RealmSigningKeyIdConverter : ValueConverter<RealmSigningKeyId, Guid>
{
public RealmSigningKeyIdConverter()
: base(id => id.Id, guid => new RealmSigningKeyId(guid))
{
}
}

View file

@ -1,11 +1,11 @@
using System.Linq.Expressions;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace IdentityShroud.Core;
namespace IdentityShroud.Core.EFCore;
public class DbConfiguration
{
@ -20,7 +20,7 @@ public class Db(
{
public virtual DbSet<Client> Clients { get; set; }
public virtual DbSet<Realm> Realms { get; set; }
public virtual DbSet<RealmKey> Keys { get; set; }
public virtual DbSet<RealmSigningKey> Keys { get; set; }
public virtual DbSet<RealmDek> Deks { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
@ -50,6 +50,10 @@ public class Db(
base.ConfigureConventions(b);
b.Properties<DekId>().HaveConversion<DekIdConverter>();
b.Properties<Dictionary<string, string>>().HaveConversion<DictionaryToJsonConverter<string, string>>();
b.Properties<JwtSigAlgName>().HaveConversion<JwtSigAlgNameConverter>();
b.Properties<KekId>().HaveConversion<KekIdConverter>();
b.Properties<KeyType>().HaveConversion<KeyTypeConverter>();
b.Properties<RealmSigningKeyId>().HaveConversion<RealmSigningKeyIdConverter>();
}
}

View file

@ -10,12 +10,12 @@
<PackageReference Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageReference Include="FluentResults" Version="4.0.0" />
<PackageReference Include="FluentValidation" Version="12.1.1" />
<PackageReference Include="jose-jwt" Version="5.2.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
</ItemGroup>
<ItemGroup>

View file

@ -19,8 +19,16 @@ public class Client
public string? Description { get; set; }
[MaxLength(20)]
public string? SignatureAlgorithm { get; set; }
public JwtSigAlgName? SignatureAlgorithm { get; set; }
/// <summary>
/// Enables confidential flows
/// </summary>
public bool Confidential { get; set; }
/// <summary>
/// Enables the client credentials flow which required Confidential to be true too.
/// </summary>
public bool AllowClientCredentialsFlow { get; set; } = false;
public required DateTime CreatedAt { get; set; }

View file

@ -1,6 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
@ -14,8 +13,9 @@ public class ClientSecret
public int Id { get; set; }
public Guid ClientId { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? Expires { get; set; }
public DateTime? RevokedAt { get; set; }
public EncryptedValue? Secret { get; set; }
public required EncryptedValue Secret { get; set; }
}
public class ClientSecretConfiguration : IEntityTypeConfiguration<ClientSecret>

View file

@ -1,8 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Security;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace IdentityShroud.Core.Model;
@ -20,12 +17,17 @@ public class Realm
public string Name { get; set; } = "";
public List<Client> Clients { get; init; } = [];
public List<RealmKey> Keys { get; init; } = [];
/// <summary>
/// Note multiple keys can be in use at the same time because different clients may be configured to use
/// a different keytype depending on their clients requirements/capabilities.
/// </summary>
public List<RealmSigningKey> TokenSigningKeys { get; init; } = [];
public List<RealmDek> Deks { get; init; } = [];
public List<RealmDek> DataEncryptionKeys { get; init; } = [];
/// <summary>
/// Can be overriden per client
/// </summary>
public string DefaultSignatureAlgorithm { get; set; } = JsonWebAlgorithm.RS256;
public JwtSigAlgName DefaultSignatureAlgorithm { get; set; } = JwtSigAlgName.RS256;
}

View file

@ -1,16 +1,18 @@
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace IdentityShroud.Core.Model;
public record RealmDek
{
public required DekId Id { get; init; }
public required bool Active { get; set; }
public required string Algorithm { get; init; }
public required KeyType Algorithm { get; init; }
public required EncryptedDek KeyData { get; init; }
public required Guid RealmId { get; init; }
public Guid RealmId { get; init; }
}
public class RealmDekConfiguration : IEntityTypeConfiguration<RealmDek>
@ -21,4 +23,5 @@ public class RealmDekConfiguration : IEntityTypeConfiguration<RealmDek>
b.HasKey(e => e.Id);
b.ComplexProperty(e => e.KeyData, e => e.IsRequired());
}
}
}

View file

@ -1,33 +1,34 @@
using System.ComponentModel.DataAnnotations.Schema;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace IdentityShroud.Core.Model;
public record RealmKey
public record RealmSigningKey
{
public required Guid Id { get; init; }
public required string KeyType { get; init; }
public required RealmSigningKeyId Id { get; init; }
public required KeyType KeyType { get; init; }
public required EncryptedDek Key { get; init; }
public required DateTime CreatedAt { get; init; }
public DateTime? RevokedAt { get; set; }
/// <summary>
/// Key with highest priority will be used. While there is not really a use case for this I know some users
/// are more comfortable replacing keys by using priority then directly deactivating the old key.
/// </summary>
public int Priority { get; set; } = 10;
public Dictionary<string, string>? PublicKeyParameters { get; set; }
}
public class RealmKeyConfiguration : IEntityTypeConfiguration<RealmKey>
public class RealmKeyConfiguration : IEntityTypeConfiguration<RealmSigningKey>
{
public void Configure(EntityTypeBuilder<RealmKey> b)
public void Configure(EntityTypeBuilder<RealmSigningKey> b)
{
b.ToTable("realm_key");
b.HasKey(e => e.Id);
b.ComplexProperty(e => e.Key, e => e.IsRequired());
b.Property(e => e.PublicKeyParameters).HasColumnType("jsonb");
}
}
}

View file

@ -0,0 +1,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Model;
[JsonConverter(typeof(RealmSigningKeyIdJsonConverter))]
public readonly record struct RealmSigningKeyId(Guid Id)
{
public override string ToString() => Id.ToString("N");
public static RealmSigningKeyId NewId()
{
return new(Guid.NewGuid());
}
}
public class RealmSigningKeyIdJsonConverter : JsonConverter<RealmSigningKeyId>
{
public override RealmSigningKeyId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new (reader.GetGuid());
public override void Write(Utf8JsonWriter writer, RealmSigningKeyId value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
}

View file

@ -1,6 +1,8 @@
namespace IdentityShroud.Core.Security;
public record struct DekId(Guid Id)
public readonly record struct DekId(Guid Id)
{
public static DekId NewId() => new(Guid.NewGuid());
public override string ToString() => Id.ToString("N");
}

View file

@ -1,5 +1,3 @@
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Security;
public record EncryptedDek(KekId KekId, byte[] Value);

View file

@ -1,5 +1,3 @@
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Security;
public record EncryptedValue(DekId DekId, byte[] Value);

View file

@ -4,7 +4,7 @@ namespace IdentityShroud.Core.Security;
public static class Encryption
{
private record struct AlgVersion(int Version, int NonceSize, int TagSize);
private readonly record struct AlgVersion(int Version, int NonceSize, int TagSize);
private static AlgVersion[] _versions =
[

View file

@ -1,6 +0,0 @@
namespace IdentityShroud.Core.Security;
public static class JsonWebAlgorithm
{
public const string RS256 = "RS256";
}

View file

@ -0,0 +1,26 @@
using System.Text.Json;
namespace IdentityShroud.Core;
public interface IJwtSignatureProvider : IDisposable
{
/*
Of the signature and MAC algorithms specified in JSON Web Algorithms
[JWA], only HMAC SHA-256 ("HS256") and "none" MUST be implemented by
conforming JWT implementations. It is RECOMMENDED that
implementations also support RSASSA-PKCS1-v1_5 with the SHA-256 hash
algorithm ("RS256") and ECDSA using the P-256 curve and the SHA-256
hash algorithm ("ES256"). Support for other algorithms and key sizes
is OPTIONAL.
*/
void WriteJwtHeaderFields(Utf8JsonWriter writer);
/// <summary>
/// Length of the binary signature in bytes.
/// </summary>
/// <returns></returns>
int GetSignatureLength();
void CalculateSignature(ReadOnlySpan<byte> jwt, Span<byte> signatureOut);
}

View file

@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
namespace IdentityShroud.Core;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public readonly record struct JwtSigAlgName(string Name) : IEquatable<JwtSigAlgName>
{
// HMAC using SHA-???
public static JwtSigAlgName HS256 => new("HS256"); // REQUIRED
public static JwtSigAlgName HS384 => new("HS384");
public static JwtSigAlgName HS512 => new("HS512");
// RSASSA-PKCS1-v1_5 using SHA-???
public static JwtSigAlgName RS256 => new("RS256");
public static JwtSigAlgName RS384 => new("RS384");
public static JwtSigAlgName RS512 => new("RS512");
public static JwtSigAlgName ES256 => new("ES256"); // ECDSA using P-256 and SHA-256
public static JwtSigAlgName ES384 => new("ES384"); // ECDSA using P-384 and SHA-384
public static JwtSigAlgName ES512 => new("ES512"); // ECDSA using P-521 and SHA-512
public override string ToString() => Name;
}

View file

@ -0,0 +1,100 @@
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core;
public static class JwtSignatureGenerator
{
/// <summary>
/// Generates a JWT signature using RS256 algorithm
/// </summary>
/// <param name="headerBase64Url">Base64Url encoded header</param>
/// <param name="payloadBase64Url">Base64Url encoded payload</param>
/// <param name="privateKey">RSA private key (PEM format or RSA parameters)</param>
/// <returns>Base64Url encoded signature</returns>
public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey)
{
// Combine header and payload with a period
string dataToSign = $"{headerBase64Url}.{payloadBase64Url}";
// Convert to bytes
byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign);
// Sign the data using RSA-SHA256
byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Convert signature to Base64Url encoding
string signature = WebEncoders.Base64UrlEncode(signatureBytes);
return signature;
}
public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey)
{
string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey);
return $"{headerBase64Url}.{payloadBase64Url}.{signature}";
}
}
public static class JwtCreator
{
public static byte[] CreateEncodedJwt(ReadOnlySpan<byte> payloadUtf8, IJwtSignatureProvider signatureProvider)
{
MemoryStream memStream = new();
Utf8JsonWriter writer = new(memStream);
WriteJwtHeader(writer, signatureProvider);
writer.Flush();
memStream.Seek(0, SeekOrigin.Begin);
int headerBase64Length = Base64Url.GetEncodedLength((int)memStream.Length);
int payloadBase64Length = Base64Url.GetEncodedLength(payloadUtf8.Length);
int signatureBase64Length = Base64Url.GetEncodedLength(signatureProvider.GetSignatureLength());
int totalLength = headerBase64Length + 1 + payloadBase64Length + 1 + signatureBase64Length;
var completeJwt = new byte[totalLength];
//
var byteArray = new byte[memStream.Length];
memStream.ReadExactly(byteArray, 0, (int)memStream.Length);
int written = Base64Url.EncodeToUtf8(byteArray, completeJwt);
if (written != headerBase64Length)
throw new Exception("expected header length did not match bytes written");
completeJwt[headerBase64Length] = (byte)'.';
written = Base64Url.EncodeToUtf8(payloadUtf8, completeJwt.AsSpan().Slice(headerBase64Length + 1, payloadBase64Length));
if (written != payloadBase64Length)
throw new Exception("expected payload length did not match bytes written");
completeJwt[headerBase64Length + 1 + payloadBase64Length] = (byte)'.';
Span<byte> signature = stackalloc byte[signatureProvider.GetSignatureLength()];
signatureProvider.CalculateSignature(
completeJwt.AsSpan().Slice(0, headerBase64Length + 1 + payloadBase64Length),
signature);
written = Base64Url.EncodeToUtf8(signature, completeJwt.AsSpan()
.Slice(headerBase64Length + 1 + payloadBase64Length + 1));
if (written != signatureBase64Length)
throw new Exception("expected signature length did not match bytes written");
return completeJwt;
}
private static void WriteJwtHeader(Utf8JsonWriter writer, IJwtSignatureProvider signatureProvider)
{
writer.WriteStartObject();
writer.WriteString("typ"u8, "JWT"u8);
signatureProvider.WriteJwtHeaderFields(writer);
writer.WriteEndObject();
}
}

View file

@ -0,0 +1,88 @@
using System.Security.Cryptography;
using System.Text.Json;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Services;
namespace IdentityShroud.Core;
public class SignatureProviderFactory(DekEncryptionService dekCryptor, IServiceProvider services)
{
public static void SelectAlgorithmAndKey(Realm realm, Client client, out JwtSigAlgName alg, out byte[] key)
{
throw new NotImplementedException();
}
public IJwtSignatureProvider Create(JwtSigAlgName algorithm, byte[] keyData)
{
//realm.DefaultSignatureAlgorithm
//realm.TokenSigningKeys
//IJwtSignatureProvider? sigProvider = services.GetKeyedService<IJwtSignatureProvider>();
throw new NotImplementedException();
}
}
public class RsaJwtSignatureProvider : IJwtSignatureProvider
{
private JwtSigAlgName _sigAlgName;
private RealmSigningKeyId _keyId;
private readonly RSA _rsa;
public RsaJwtSignatureProvider(DekEncryptionService dekCryptor,
RealmSigningKey privateKey,
JwtSigAlgName sigAlgName)
{
_sigAlgName = sigAlgName;
_keyId = privateKey.Id;
byte[] key = dekCryptor.Decrypt(privateKey.Key);
_rsa = RSA.Create();
_rsa.ImportPkcs8PrivateKey(key, out int _);
}
/*
+-------------------+---------------------------------+
| "alg" Param Value | Digital Signature Algorithm |
+-------------------+---------------------------------+
| RS256 | RSASSA-PKCS1-v1_5 using SHA-256 |
| RS384 | RSASSA-PKCS1-v1_5 using SHA-384 |
| RS512 | RSASSA-PKCS1-v1_5 using SHA-512 |
+-------------------+---------------------------------+
*/
public void WriteJwtHeaderFields(Utf8JsonWriter writer)
{
writer.WriteString("alg"u8, _sigAlgName.ToString());
writer.WriteString("kid"u8, _keyId.ToString());
}
public int GetSignatureLength()
{
return _rsa.KeySize / 8;
}
public void CalculateSignature(ReadOnlySpan<byte> jwt, Span<byte> sig)
{
_rsa.SignData(jwt, sig, GetHashAlgorithmName(), RSASignaturePadding.Pkcs1);
}
public void Dispose()
{
_rsa.Dispose();
}
private HashAlgorithmName GetHashAlgorithmName()
=> _sigAlgName.Name switch
{
"RS256" => HashAlgorithmName.SHA256,
"RS384" => HashAlgorithmName.SHA384,
"RS512" => HashAlgorithmName.SHA512,
_ => throw new ArgumentException("Invalid algorithm for RsaJwtSignatureProvider")
};
}

View file

@ -1,38 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.WebUtilities;
namespace IdentityShroud.Core;
public static class JwtSignatureGenerator
{
/// <summary>
/// Generates a JWT signature using RS256 algorithm
/// </summary>
/// <param name="headerBase64Url">Base64Url encoded header</param>
/// <param name="payloadBase64Url">Base64Url encoded payload</param>
/// <param name="privateKey">RSA private key (PEM format or RSA parameters)</param>
/// <returns>Base64Url encoded signature</returns>
public static string GenerateRS256Signature(string headerBase64Url, string payloadBase64Url, RSA privateKey)
{
// Combine header and payload with a period
string dataToSign = $"{headerBase64Url}.{payloadBase64Url}";
// Convert to bytes
byte[] dataBytes = Encoding.UTF8.GetBytes(dataToSign);
// Sign the data using RSA-SHA256
byte[] signatureBytes = privateKey.SignData(dataBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
// Convert signature to Base64Url encoding
string signature = WebEncoders.Base64UrlEncode(signatureBytes);
return signature;
}
public static string GenerateCompleteJwt(string headerBase64Url, string payloadBase64Url, RSA privateKey)
{
string signature = GenerateRS256Signature(headerBase64Url, payloadBase64Url, privateKey);
return $"{headerBase64Url}.{payloadBase64Url}.{signature}";
}
}

View file

@ -0,0 +1,10 @@
namespace IdentityShroud.Core.Security.Keys.Aes;
public class AesKeyPolicy : KeyPolicy
{
public AesKeyPolicy()
{
KeyType = KeyType.AES;
KeySize = 256;
}
}

View file

@ -0,0 +1,19 @@
using System.Security.Cryptography;
using IdentityShroud.Core.Messages;
namespace IdentityShroud.Core.Security.Keys.Aes;
public class AesProvider : IKeyProvider
{
public bool IsPublic => false;
public KeyData CreateKey(KeyPolicy policy)
{
return new KeyData(RandomNumberGenerator.GetBytes(policy.KeySize / 8));
}
public void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk)
{
// Can we use this for Jwe?
throw new NotImplementedException();
}
}

View file

@ -2,17 +2,32 @@ using IdentityShroud.Core.Messages;
namespace IdentityShroud.Core.Security.Keys;
public abstract class KeyPolicy
public class KeyPolicy
{
public abstract string KeyType { get; }
public KeyType KeyType { get; protected init; }
public int KeySize { get; protected init; }
}
public record KeyData(byte[] PrivateKey, Dictionary<string, string>? PublicKeyParameters = null)
{
/// <summary>
/// The data to be kept private, also used for symmetric keys
/// </summary>
public byte[] PrivateKey { get; set; } = PrivateKey;
public Dictionary<string, string>? PublicKeyParameters { get; set; } = PublicKeyParameters;
}
public interface IKeyProvider
{
byte[] CreateKey(KeyPolicy policy);
/// <summary>
/// Returns true when this key uses public key cryptography
/// </summary>
bool IsPublic { get; }
KeyData CreateKey(KeyPolicy policy);
void SetJwkParameters(byte[] key, JsonWebKey jwk);
void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk);
}

View file

@ -3,5 +3,5 @@ namespace IdentityShroud.Core.Security.Keys;
public interface IKeyProviderFactory
{
public IKeyProvider CreateProvider(string keyType);
public IKeyProvider CreateProvider(KeyType keyType);
}

View file

@ -1,15 +1,18 @@
using IdentityShroud.Core.Security.Keys.Aes;
using IdentityShroud.Core.Security.Keys.Rsa;
namespace IdentityShroud.Core.Security.Keys;
public class KeyProviderFactory : IKeyProviderFactory
{
public IKeyProvider CreateProvider(string keyType)
public IKeyProvider CreateProvider(KeyType keyType)
{
switch (keyType)
switch (keyType.Name)
{
case "RSA":
return new RsaProvider();
case "AES":
return new AesProvider();
default:
throw new NotImplementedException();
}

View file

@ -0,0 +1,21 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace IdentityShroud.Core.Security.Keys;
[JsonConverter(typeof(KeyTypeJsonConverter))]
public readonly record struct KeyType(string Name)
{
public static KeyType AES => new("AES");
public static KeyType RSA => new("RSA");
public override string ToString() => Name;
}
public class KeyTypeJsonConverter : JsonConverter<KeyType>
{
public override KeyType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new KeyType(reader.GetString()!);
public override void Write(Utf8JsonWriter writer, KeyType value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToString());
}

View file

@ -0,0 +1,10 @@
namespace IdentityShroud.Core.Security.Keys.Rsa;
public class RsaKeyPolicy : KeyPolicy
{
public RsaKeyPolicy()
{
KeyType = KeyType.RSA;
KeySize = 2048;
}
}

View file

@ -4,32 +4,31 @@ using IdentityShroud.Core.Messages;
namespace IdentityShroud.Core.Security.Keys.Rsa;
public class RsaKeyPolicy : KeyPolicy
{
public override string KeyType => "RSA";
public int KeySize { get; } = 2048;
}
public class RsaProvider : IKeyProvider
{
public byte[] CreateKey(KeyPolicy policy)
public bool IsPublic => true;
public KeyData CreateKey(KeyPolicy policy)
{
if (policy is RsaKeyPolicy p)
{
using var rsa = RSA.Create(p.KeySize);
return rsa.ExportPkcs8PrivateKey();
var publicParamaters = rsa.ExportParameters(includePrivateParameters: false);
return new KeyData(
rsa.ExportPkcs8PrivateKey(),
new()
{
["e"] = Base64Url.EncodeToString(publicParamaters.Exponent),
["n"] = Base64Url.EncodeToString(publicParamaters.Modulus),
});
}
throw new ArgumentException("Incorrect policy type", nameof(policy));
}
public void SetJwkParameters(byte[] key, JsonWebKey jwk)
public void SetJwkParameters(Dictionary<string, string> parameters, JsonWebKey jwk)
{
using var rsa = RSA.Create();
rsa.ImportPkcs8PrivateKey(key, out _);
var parameters = rsa.ExportParameters(includePrivateParameters: false);
jwk.Exponent = Base64Url.EncodeToString(parameters.Exponent);
jwk.Modulus = Base64Url.EncodeToString(parameters.Modulus);
jwk.Exponent = parameters["e"];
jwk.Modulus = parameters["n"];
}
}

View file

@ -1,5 +1,7 @@
using System.Security.Cryptography;
using FluentValidation;
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Model;
using Microsoft.EntityFrameworkCore;
@ -8,24 +10,35 @@ namespace IdentityShroud.Core.Services;
public class ClientService(
Db db,
IDataEncryptionService cryptor,
IValidator<ClientCreateRequest> clientCreateValidator,
IClock clock) : IClientService
{
public async Task<Result<Client>> Create(Guid realmId, ClientCreateRequest request, CancellationToken ct = default)
{
clientCreateValidator.ValidateAndThrow(request);
Realm realm = await db.Realms.FirstOrDefaultAsync(e => e.Id == realmId, ct)
?? throw new InvalidOperationException("Require the id of an existing realm");
Client client = new()
{
RealmId = realmId,
ClientId = request.ClientId,
Name = request.Name,
Description = request.Description,
SignatureAlgorithm = request.SignatureAlgorithm,
AllowClientCredentialsFlow = request.AllowClientCredentialsFlow ?? false,
SignatureAlgorithm = request.SignatureAlgorithm is null ? null : new(request.SignatureAlgorithm),
Confidential = request.Confidential,
AllowClientCredentialsFlow = request.AllowClientCredentialsFlow,
CreatedAt = clock.UtcNow(),
};
if (client.AllowClientCredentialsFlow)
if (request.GenerateSecret is true)
{
client.Secrets.Add(CreateSecret());
await db.Entry(realm).Collection(r => r.DataEncryptionKeys)
.Query()
.LoadAsync(ct);
client.Secrets.Add(CreateSecret(realm));
}
await db.AddAsync(client, ct);
@ -50,15 +63,17 @@ public class ClientService(
return await db.Clients.FirstOrDefaultAsync(c => c.Id == id && c.RealmId == realmId, ct);
}
private ClientSecret CreateSecret()
private ClientSecret CreateSecret(Realm realm)
{
Span<byte> secret = stackalloc byte[24];
RandomNumberGenerator.Fill(secret);
var dek = realm.DataEncryptionKeys.Single(k => k.Active);
return new ClientSecret()
{
CreatedAt = clock.UtcNow(),
Secret = cryptor.Encrypt(secret.ToArray()),
Secret = cryptor.Encrypt(dek, secret),
};
}

View file

@ -5,37 +5,23 @@ using IdentityShroud.Core.Security;
namespace IdentityShroud.Core.Services;
public class DataEncryptionService(
IRealmContext realmContext,
IDekEncryptionService dekCryptor) : IDataEncryptionService
{
// Note this array is expected to have one item in it most of the during key rotation it will have two
// until it is ensured the old key can safely be removed. More then two will work but is not really expected.
private IList<RealmDek>? _deks = null;
private IList<RealmDek> GetDeks()
public EncryptedValue Encrypt(RealmDek dek, ReadOnlySpan<byte> plain)
{
if (_deks is null)
_deks = realmContext.GetDeks().Result;
return _deks;
}
private RealmDek GetActiveDek() => GetDeks().Single(d => d.Active);
private RealmDek GetKey(DekId id) => GetDeks().Single(d => d.Id == id);
public byte[] Decrypt(EncryptedValue input)
{
var dek = GetKey(input.DekId);
var key = dekCryptor.Decrypt(dek.KeyData);
return Encryption.Decrypt(input.Value, key);
}
public EncryptedValue Encrypt(ReadOnlySpan<byte> plain)
{
var dek = GetActiveDek();
var key = dekCryptor.Decrypt(dek.KeyData);
byte[] cipher = Encryption.Encrypt(plain, key);
return new (dek.Id, cipher);
}
public byte[] Decrypt(IReadOnlyList<RealmDek> deks, EncryptedValue input)
{
// Note a missing key SHOULD not happen. If it does happen something has seriously gone wrong like
// - Old key removed before migration completed (should not be possible)
// - Wrong keyset because of programming error.
var dek = deks.SingleOrDefault(d => d.Id == input.DekId)
?? throw new InvalidOperationException("Required key not found");
var key = dekCryptor.Decrypt(dek.KeyData);
return Encryption.Decrypt(input.Value, key);
}
}

View file

@ -1,46 +1,16 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.Messages;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security.Keys;
namespace IdentityShroud.Core.Services;
public class KeyService(
IDekEncryptionService cryptor,
IKeyProviderFactory keyProviderFactory,
IClock clock) : IKeyService
IKeyProviderFactory keyProviderFactory) : IKeyService
{
public RealmKey CreateKey(KeyPolicy policy)
public CreateKeyResponse CreateKey(KeyPolicy policy)
{
IKeyProvider provider = keyProviderFactory.CreateProvider(policy.KeyType);
var plainKey = provider.CreateKey(policy);
KeyData plainKey = provider.CreateKey(policy);
return CreateKey(policy.KeyType, plainKey);
return new CreateKeyResponse(policy.KeyType, plainKey);
}
public JsonWebKey? CreateJsonWebKey(RealmKey realmKey)
{
JsonWebKey jwk = new()
{
KeyId = realmKey.Id.ToString(),
KeyType = realmKey.KeyType,
Use = "sig",
};
IKeyProvider provider = keyProviderFactory.CreateProvider(realmKey.KeyType);
provider.SetJwkParameters(
cryptor.Decrypt(realmKey.Key),
jwk);
return jwk;
}
private RealmKey CreateKey(string keyType, byte[] plainKey) =>
new RealmKey()
{
Id = Guid.NewGuid(),
KeyType = keyType,
Key = cryptor.Encrypt(plainKey),
CreatedAt = clock.UtcNow(),
};
}

View file

@ -0,0 +1,30 @@
namespace IdentityShroud.Core.Services.OpenId;
public interface ITokenService
{
Task<Result<TokenResponse>> Handle(
Dictionary<string, string> form,
string? basicAuthUser,
string? basicAuthPassword,
CancellationToken ct = default);
}
public class TokenService : ITokenService
{
public async Task<Result<TokenResponse>> Handle(
Dictionary<string, string> form,
string? basicAuthUser,
string? basicAuthPassword,
CancellationToken ct = default)
{
return new();
}
public async Task<Result<TokenResponse>> ClientCredentialsFlow(
string clientId,
string clientSecret,
CancellationToken ct = default)
{
return new();
}
}

View file

@ -16,11 +16,11 @@ public class RealmContext(
public async Task<IList<RealmDek>> GetDeks(CancellationToken ct = default)
{
Realm realm = GetRealm();
if (realm.Deks.Count == 0)
if (realm.DataEncryptionKeys.Count == 0)
{
await realmService.LoadDeks(realm);
}
return realm.Deks;
return realm.DataEncryptionKeys;
}
}

View file

@ -1,18 +1,21 @@
using IdentityShroud.Core.Contracts;
using IdentityShroud.Core.EFCore;
using IdentityShroud.Core.Helpers;
using IdentityShroud.Core.Messages.Realm;
using IdentityShroud.Core.Model;
using IdentityShroud.Core.Security;
using IdentityShroud.Core.Security.Keys;
using IdentityShroud.Core.Security.Keys.Aes;
using IdentityShroud.Core.Security.Keys.Rsa;
using Microsoft.EntityFrameworkCore;
namespace IdentityShroud.Core.Services;
public record RealmCreateResponse(Guid Id, string Slug, string Name);
public class RealmService(
Db db,
IKeyService keyService) : IRealmService
IKeyService keyService,
IDekEncryptionService dekCryptor,
IClock clock) : IRealmService
{
public async Task<Realm?> FindById(Guid id, CancellationToken ct = default)
{
@ -26,7 +29,7 @@ public class RealmService(
.SingleOrDefaultAsync(r => r.Slug == slug, ct);
}
public async Task<Result<RealmCreateResponse>> Create(RealmCreateRequest request, CancellationToken ct = default)
public async Task<Result<Realm>> Create(RealmCreateRequest request, CancellationToken ct = default)
{
Realm realm = new()
{
@ -35,26 +38,52 @@ public class RealmService(
Name = request.Name,
};
realm.Keys.Add(keyService.CreateKey(GetKeyPolicy(realm)));
realm.TokenSigningKeys.Add(CreateSigningKey(realm));
realm.DataEncryptionKeys.Add(CreateDataEncryptionKey(realm));
db.Add(realm);
await db.SaveChangesAsync(ct);
return new RealmCreateResponse(
realm.Id, realm.Slug, realm.Name);
return realm;
}
private RealmSigningKey CreateSigningKey(Realm realm)
{
var k = keyService.CreateKey(GetSigningKeyPolicy(realm));
return new RealmSigningKey
{
Id = RealmSigningKeyId.NewId(),
KeyType = k.KeyType,
Key = dekCryptor.Encrypt(k.Key.PrivateKey),
PublicKeyParameters = k.Key.PublicKeyParameters,
CreatedAt = clock.UtcNow(),
};
}
private RealmDek CreateDataEncryptionKey(Realm realm)
{
var k = keyService.CreateKey(GetDataKeyPolicy(realm));
return new RealmDek()
{
Id = DekId.NewId(),
Active = true,
Algorithm = k.KeyType,
KeyData = dekCryptor.Encrypt(k.Key.PrivateKey),
};
}
/// <summary>
/// Place holder for getting policies from the realm and falling back to sane defaults when no policies have been set.
/// </summary>
/// <param name="_"></param>
/// <returns></returns>
private KeyPolicy GetKeyPolicy(Realm _) => new RsaKeyPolicy();
private KeyPolicy GetSigningKeyPolicy(Realm _) => new RsaKeyPolicy();
private KeyPolicy GetDataKeyPolicy(Realm _) => new AesKeyPolicy();
public async Task LoadActiveKeys(Realm realm)
{
await db.Entry(realm).Collection(r => r.Keys)
await db.Entry(realm).Collection(r => r.TokenSigningKeys)
.Query()
.Where(k => k.RevokedAt == null)
.LoadAsync();
@ -62,7 +91,7 @@ public class RealmService(
public async Task LoadDeks(Realm realm)
{
await db.Entry(realm).Collection(r => r.Deks)
await db.Entry(realm).Collection(r => r.DataEncryptionKeys)
.Query()
.LoadAsync();
}