OPC # 0001: Extract Clarity into standalone repo

This commit is contained in:
amadzarak
2026-04-25 17:26:35 -04:00
commit 60821e219c
65 changed files with 10203 additions and 0 deletions
@@ -0,0 +1,27 @@
using Clarity.Server.Entity;
using Microsoft.EntityFrameworkCore;
namespace Clarity.Server.Data
{
public class ApplicationDbContext : DbContext
{
public DbSet<Profile> Profiles => Set<Profile>();
public DbSet<SysParams> SysParams => Set<SysParams>();
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Profile>(entity =>
{
entity.HasKey(p => p.Id);
entity.HasIndex(p => p.KeycloakSubject).IsUnique();
entity.Property(p => p.KeycloakSubject).IsRequired();
});
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Clarity.Server.Data;
public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
{
public ApplicationDbContext CreateDbContext(string[] args)
{
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile("appsettings.Development.json", optional: true)
.AddEnvironmentVariables()
.Build();
var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
optionsBuilder.UseNpgsql(configuration.GetConnectionString("postgresdb"));
return new ApplicationDbContext(optionsBuilder.Options);
}
}
+37
View File
@@ -0,0 +1,37 @@
using System.Security.Cryptography;
using System.Text;
namespace Clarity.Server.Data;
public static class BlindIndexHelper
{
// In a real environment, this "Pepper" comes from your TenantKeyProvider or Vault!
// It must NEVER change once records are written, or searches will break.
public static string Compute(string? input, byte[] staticPepper)
{
if (string.IsNullOrWhiteSpace(input)) return string.Empty;
// 1. Normalize (Remove dashes, spaces, make uppercase)
var normalized = input.Replace("-", "").Replace(" ", "").ToUpperInvariant();
// 2. Hash using HMAC-SHA256 and the static Pepper
using var hmac = new HMACSHA256(staticPepper);
var hashBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(normalized));
// 3. Return Base64 for database storage
return Convert.ToBase64String(hashBytes);
}
}
[AttributeUsage(AttributeTargets.Property)]
public class BlindIndexedAttribute : Attribute
{
public string TargetPropertyName { get; }
public BlindIndexedAttribute(string targetPropertyName)
{
TargetPropertyName = targetPropertyName;
}
}
@@ -0,0 +1,95 @@
// <auto-generated />
using System;
using Clarity.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Clarity.Server.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260424021033_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Clarity.Server.Data.SysParams", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("EncryptedKek")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("SysParams");
});
modelBuilder.Entity("Clarity.Server.Entity.Profile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("EncryptedDek")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("FirstName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("KeycloakSubject")
.IsRequired()
.HasColumnType("text");
b.Property<string>("LastName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MiddleName")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("OnboardingComplete")
.HasColumnType("boolean");
b.Property<string>("Ssn")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Tenant")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("KeycloakSubject")
.IsUnique();
b.ToTable("Profiles");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,65 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Clarity.Server.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Profiles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
KeycloakSubject = table.Column<string>(type: "text", nullable: false),
FirstName = table.Column<string>(type: "text", nullable: false),
MiddleName = table.Column<string>(type: "text", nullable: false),
LastName = table.Column<string>(type: "text", nullable: false),
Ssn = table.Column<string>(type: "text", nullable: false),
OnboardingComplete = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
EncryptedDek = table.Column<byte[]>(type: "bytea", nullable: false),
Tenant = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Profiles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "SysParams",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
EncryptedKek = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SysParams", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Profiles_KeycloakSubject",
table: "Profiles",
column: "KeycloakSubject",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Profiles");
migrationBuilder.DropTable(
name: "SysParams");
}
}
}
@@ -0,0 +1,92 @@
// <auto-generated />
using System;
using Clarity.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Clarity.Server.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.6")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Clarity.Server.Data.SysParams", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("EncryptedKek")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("SysParams");
});
modelBuilder.Entity("Clarity.Server.Entity.Profile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<byte[]>("EncryptedDek")
.IsRequired()
.HasColumnType("bytea");
b.Property<string>("FirstName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("KeycloakSubject")
.IsRequired()
.HasColumnType("text");
b.Property<string>("LastName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MiddleName")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("OnboardingComplete")
.HasColumnType("boolean");
b.Property<string>("Ssn")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Tenant")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("KeycloakSubject")
.IsUnique();
b.ToTable("Profiles");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,272 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Serialization;
using VaultSharp;
using VaultSharp.V1.SecretsEngines;
using VaultSharp.V1.SecretsEngines.Transit;
using VaultSharp.V1.Commons;
using System.Collections.Generic;
namespace Clarity.Server.Data;
public interface IPIIEntity
{
public byte[] EncryptedDek { get; set; }
public string Tenant { get; set; }
}
public interface IPIIVault
{
Task<(byte[] PlaintextKey, string CiphertextKey)> GenerateTenantKEKAsync(string tenantId);
Task<byte[]> DecryptTenantKEKAsync(string encryptedKek, string tenantId);
Task<string> RewrapTenantKEKAsync(string encryptedKek, string tenantId);
}
public class TenantKeyProvider
{
public byte[]? CurrentKey { get; private set; }
public void SetKey(byte[] key) => CurrentKey = key;
}
public class PIIVault : IPIIVault
{
private readonly IVaultClient _vaultClient;
private const string TransitPath = "clarity-transit";
private const string MasterKeyName = "master-key";
public PIIVault(IVaultClient vaultClient)
{
_vaultClient = vaultClient;
}
// https://github.com/rajanadar/VaultSharp/blob/master/test/VaultSharp.Samples/Backends/Secrets/TransitSecretsBackendSamples.cs
public async Task<(byte[] PlaintextKey, string CiphertextKey)> GenerateTenantKEKAsync(string tenantId)
{
var encodedContext = Convert.ToBase64String(Encoding.UTF8.GetBytes(tenantId));
var dataKeyOptions = new DataKeyRequestOptions
{
DataKeyType = TransitDataKeyType.plaintext,
Base64EncodedContext = encodedContext
};
Secret<DataKeyResponse> response = await _vaultClient.V1.Secrets.Transit.GenerateDataKeyAsync(
MasterKeyName,
dataKeyOptions,
TransitPath);
byte[] rawPlaintextBytes = Convert.FromBase64String(response.Data.Base64EncodedPlainText);
string ciphertext = response.Data.CipherText;
return (rawPlaintextBytes, ciphertext);
}
public async Task<byte[]> DecryptTenantKEKAsync(string encryptedKek, string tenantId)
{
var encodedContext = Convert.ToBase64String(Encoding.UTF8.GetBytes(tenantId));
var decryptOptions = new DecryptRequestOptions
{
CipherText = encryptedKek,
Base64EncodedContext = encodedContext
};
Secret<DecryptionResponse> response = await _vaultClient.V1.Secrets.Transit.DecryptAsync(
MasterKeyName,
decryptOptions,
TransitPath);
return Convert.FromBase64String(response.Data.Base64EncodedPlainText);
}
public async Task<string> RewrapTenantKEKAsync(string encryptedKek, string tenantId)
{
var encodedContext = Convert.ToBase64String(Encoding.UTF8.GetBytes(tenantId));
var rewrapOptions = new RewrapRequestOptions
{
CipherText = encryptedKek,
Base64EncodedContext = encodedContext
};
var response = await _vaultClient.V1.Secrets.Transit.RewrapAsync(
MasterKeyName,
rewrapOptions,
TransitPath);
return response.Data.CipherText;
}
}
public static class TenantSecurityExtensions
{
public static async Task InitializeTenantSecurityAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var piiVault = scope.ServiceProvider.GetRequiredService<IPIIVault>();
var keyProvider = scope.ServiceProvider.GetRequiredService<TenantKeyProvider>();
var tenantId = app.Configuration["Tenant__Id"] ?? "CLARITY_01000000";
var settings = await db.SysParams.FirstOrDefaultAsync();
if (settings == null)
{
// Generate the key!
var (plaintext, ciphertext) = await piiVault.GenerateTenantKEKAsync(tenantId);
// Save ciphertext to DB so we have it for the next restart
db.SysParams.Add(new SysParams { EncryptedKek = ciphertext });
await db.SaveChangesAsync();
// Store plaintext in RAM
keyProvider.SetKey(plaintext);
}
else
{
// Ask Vault to unwrap the existing key using the Master Key + Context
var plaintext = await piiVault.DecryptTenantKEKAsync(settings.EncryptedKek, tenantId);
// Store plaintext back in RAM
keyProvider.SetKey(plaintext);
}
}
}
[AttributeUsage(AttributeTargets.Property)]
public class PiiDataAttribute : Attribute
{
}
public class EnvelopeEncryptionInterceptor : SaveChangesInterceptor, IMaterializationInterceptor
{
private readonly TenantKeyProvider _keyProvider;
public EnvelopeEncryptionInterceptor(TenantKeyProvider keyProvider)
{
_keyProvider = keyProvider;
}
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
EncryptEntities(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
EncryptEntities(eventData.Context);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
public object InitializedInstance(MaterializationInterceptionData materializationData, object entity)
{
if (entity is not IPIIEntity piiEntity || _keyProvider.CurrentKey == null || piiEntity.EncryptedDek == null)
{
return entity;
}
try
{
// 1. Open the Envelope (Unwrap Row DEK)
byte[] rowDek = AesGcmHelper.Decrypt(piiEntity.EncryptedDek, _keyProvider.CurrentKey);
var props = entity.GetType().GetProperties()
.Where(p => p.PropertyType == typeof(string) && Attribute.IsDefined(p, typeof(PiiDataAttribute)));
foreach (var prop in props)
{
var cipherText = (string?)prop.GetValue(entity);
if (!string.IsNullOrEmpty(cipherText))
{
prop.SetValue(entity, AesGcmHelper.DecryptString(cipherText, rowDek));
}
}
}
catch (CryptographicException) { /* Log unauthorized tampering! */ }
return entity;
}
private void EncryptEntities(DbContext? context)
{
if (context == null || _keyProvider.CurrentKey == null) return;
var entries = context.ChangeTracker.Entries<IPIIEntity>()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
foreach (var entry in entries)
{
var entity = entry.Entity;
byte[] rowDek = RandomNumberGenerator.GetBytes(32);
// 2. Wrap the Row DEK
entity.EncryptedDek = AesGcmHelper.Encrypt(rowDek, _keyProvider.CurrentKey);
var props = entity.GetType().GetProperties()
.Where(p => p.PropertyType == typeof(string) && Attribute.IsDefined(p, typeof(PiiDataAttribute)));
foreach (var prop in props)
{
var plainText = (string?)prop.GetValue(entity);
if (!string.IsNullOrEmpty(plainText))
{
prop.SetValue(entity, AesGcmHelper.EncryptString(plainText, rowDek));
}
}
}
}
}
/// <summary>
/// AES-GCM High-Performance Helper
/// Uses Span<byte> for speed, but returns byte[] for EF Core storage.
/// </summary>
public static class AesGcmHelper
{
private const int NonceSize = 12; // AesGcm.NonceByteSizes.MaxSize
private const int TagSize = 16; // AesGcm.TagByteSizes.MaxSize
public static byte[] Encrypt(ReadOnlySpan<byte> data, ReadOnlySpan<byte> key)
{
// Final buffer: [Nonce (12)] [Tag (16)] [Ciphertext (N)]
byte[] result = new byte[NonceSize + TagSize + data.Length];
Span<byte> nonce = result.AsSpan(0, NonceSize);
Span<byte> tag = result.AsSpan(NonceSize, TagSize);
Span<byte> cipher = result.AsSpan(NonceSize + TagSize);
RandomNumberGenerator.Fill(nonce);
using var aes = new AesGcm(key);
aes.Encrypt(nonce, data, cipher, tag);
return result;
}
public static byte[] Decrypt(ReadOnlySpan<byte> encryptedData, ReadOnlySpan<byte> key)
{
if (encryptedData.Length < NonceSize + TagSize)
throw new CryptographicException("Ciphertext too short.");
ReadOnlySpan<byte> nonce = encryptedData.Slice(0, NonceSize);
ReadOnlySpan<byte> tag = encryptedData.Slice(NonceSize, TagSize);
ReadOnlySpan<byte> cipher = encryptedData.Slice(NonceSize + TagSize);
byte[] result = new byte[cipher.Length];
using var aes = new AesGcm(key);
aes.Decrypt(nonce, cipher, tag, result);
return result;
}
public static string EncryptString(string text, byte[] key) =>
Convert.ToBase64String(Encrypt(System.Text.Encoding.UTF8.GetBytes(text), key));
public static string DecryptString(string cipher, byte[] key) =>
System.Text.Encoding.UTF8.GetString(Decrypt(Convert.FromBase64String(cipher), key));
}
+12
View File
@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
namespace Clarity.Server.Data
{
public class SysParams
{
[Key]
public int Id { get; set; } = 1;
public string EncryptedKek { get; set; } = string.Empty;
}
}