DKNet

DKNet.Svc.Encryption

Explicitly-invoked cryptography toolkit for application code — AES-GCM and RSA encryption, RSA signing, HMAC and SHA hashing, and Base64/Base64URL helpers, all string-in/string-out.

[!NOTE] Looking for transparent EF Core column encryption instead — where an attribute on an entity property does the work automatically on save/load? That’s a different package: DKNet.EfCore.Encryption. Use DKNet.Svc.Encryption when you want to call Encrypt/Decrypt yourself; use DKNet.EfCore.Encryption when you want a column encrypted without touching the code that reads and writes the entity. The two don’t share implementation — DKNet.EfCore.Encryption’s AES-GCM provider has its own key-provider abstraction.

✨ Why use it?

Reach for it whenever application code needs to encrypt a value, hash a token, sign a webhook payload, or verify an HMAC — anywhere the call site should control exactly when encryption happens.

🚀 Quick Start

dotnet add package DKNet.Svc.Encryption
using DKNet.Svc.Encryption;

// IShaHashing and IHmacHashing — the keyless hashing services, transient
builder.Services.AddEncryptionServices();

// Every cipher is opt-in and takes the key you supply — one singleton per key
builder.Services.AddAesGcmEncryption(builder.Configuration["Crypto:AesKey"]!);
builder.Services.AddRsaEncryption(builder.Configuration["Crypto:RsaPrivateKey"]!);
public sealed class SecretStore(IAesGcmEncryption aesGcm)
{
    public string Protect(string plainText) => aesGcm.EncryptString(plainText);
    public string Reveal(string cipherPackage) => aesGcm.DecryptString(cipherPackage);
}

AddEncryptionServices registers no cipher — only the hashing services. Each cipher has its own key-taking registration: AddAesGcmEncryption and AddRsaEncryption. All three methods are idempotent — calling any of them twice does not double-register — and the two cipher methods throw ArgumentException for a null, empty, or whitespace key.

[!IMPORTANT] A cipher’s key is yours to supply and persist. AddAesGcmEncryption(base64Key) registers IAesGcmEncryption as a singleton built from that key, so every resolution shares it and a value encrypted in one request is decryptable in the next. Source the key from configuration or a key vault — never hardcode it. new AesGcmEncryption() with no key still generates a random one, which is fine only for data that never outlives the process.

🧩 Features

AES-GCM (IAesGcmEncryption) — the encryption to reach for

string EncryptString(string plainText, byte[]? associatedData = null);
string DecryptString(string cipherPackage, byte[]? associatedData = null);
string Encrypt(string plainText, string base64Key, byte[]? associatedData = null);
string Decrypt(string cipherPackage, string base64Key, byte[]? associatedData = null);
string Key { get; } // Base64, persist this to decrypt later

new AesGcmEncryption() generates a random 256-bit key; new AesGcmEncryption(existingBase64Key) reconstructs an instance from a previously persisted key (accepting 128/192/256-bit keys, and rejecting a key containing : — that shape belonged to the removed AES-CBC type’s key:iv format). Every call produces a fresh random 12-byte nonce and 16-byte tag, so ciphertext is never deterministic even for the same plaintext:

using var aes = new AesGcmEncryption();          // keep aes.Key to decrypt later
var package = aes.EncryptString("4111-1111-1111-1111");
var plain = aes.DecryptString(package);

The returned package is Base64 of nonce:tag:cipher (each part itself Base64) — treat it as opaque and pass it around whole. Pass associatedData (AAD) when tamper detection should cover context outside the ciphertext; decrypting with different AAD throws CryptographicException. The Encrypt/Decrypt overloads that take a base64Key throw InvalidOperationException when it does not match the instance’s own key — they never silently re-key. The comparison decodes both keys and runs CryptographicOperations.FixedTimeEquals on the resulting bytes, so a wrong key takes the same time to reject no matter how much of it happened to be right, and a base64 string that decodes to the same bytes as the instance’s own key is accepted even if the two strings differ textually.

RSA (IRsaEncryption) — asymmetric encrypt and sign

new RsaEncryption(int keySize = 2048);               // generates a new key pair
new RsaEncryption(string privateKeyBase64);          // loads an existing private key (PKCS#1 DER, Base64)
RsaEncryption.FromPublicKey(string publicKeyBase64); // public-only instance

string Encrypt(string plainText);   // OAEP-SHA256
string Decrypt(string base64CipherText);
string Sign(string data);           // PKCS#1 v1.5 + SHA256
bool Verify(string data, string base64Signature);
string PublicKey { get; }
string? PrivateKey { get; }         // null on a public-only instance

A public-only instance can Encrypt and Verify but throws InvalidOperationException on Decrypt/Sign — deploy it on the side that only sends or verifies, and keep the private key off that side entirely:

// signing side (has the private key)
using var signer = new RsaEncryption(privateKeyBase64);
var signature = signer.Sign(payload);

// verifying side (public key only)
using var verifier = RsaEncryption.FromPublicKey(publicKeyBase64);
var ok = verifier.Verify(payload, signature);

PublicKey and PrivateKey are Base64 of the raw PKCS#1 structures (ExportRSAPublicKey / ExportRSAPrivateKey) — not PEM, so don’t paste them into a -----BEGIN block without re-encoding.

HMAC signatures (IHmacHashing)

string ComputeSha256(string message, string secretKey, bool asBase64 = true);
string ComputeSha512(string message, string secretKey, bool asBase64 = true);
bool VerifySha256(string message, string secretKey, string expectedSignature, bool signatureIsBase64 = true);
bool VerifySha512(string message, string secretKey, string expectedSignature, bool signatureIsBase64 = true);

Set asBase64 = false to get upper-case hex instead, and match that choice with signatureIsBase64 on the verify call — a mismatched encoding returns false rather than throwing. The key bytes are zeroed after each computation, and comparison runs through CryptographicOperations.FixedTimeEquals:

// verifying an inbound webhook signature
var trusted = hmac.VerifySha256(rawBody, webhookSecret, header["X-Signature"]!);

Blank message, secretKey, or expectedSignature throws ArgumentException.

Content hashes (IShaHashing)

string ComputeSha256(string input, bool upperCase = false);
string ComputeSha512(string input, bool upperCase = false);
bool VerifySha256(string input, string expectedHex, bool ignoreCase = true);
bool VerifySha512(string input, string expectedHex, bool ignoreCase = true);

Output is hex, lower-case unless upperCase is set. Verification is hex-only (expectedHex), constant-time, and returns false — instead of throwing — when the expected value isn’t valid hex. Empty string input is allowed; null throws.

Base64 / Base64URL helpers

Base64StringExtensions exposes ToBase64String, FromBase64String, ToBase64UrlString, FromBase64UrlString, and IsBase64String as this string extension methods, so both call styles work:

using DKNet.Svc.Encryption;

var token = payloadJson.ToBase64UrlString();          // extension-method call
var back = Base64StringExtensions.FromBase64UrlString(token); // static call — still compiles

The URL variants are useful for JWT-style payloads without pulling in a JWT library. Whitespace or empty input returns string.Empty from both decode helpers, and IsBase64String returns false for it.

AES-CBC (IAesEncryption) — removed

IAesEncryption, AesEncryption, and EncryptionSetup.AddAesEncryption have been removed, not merely deprecated. AES-CBC as this type used it kept a fixed IV embedded in the key, so encrypting the same plaintext twice always produced the same ciphertext — a real leak of information about the data (e.g. two customers sharing a password hash, or a repeated field, becomes visible from ciphertext alone) rather than a theoretical concern. IAesGcmEncryption (AddAesGcmEncryption) is authenticated, uses a fresh random nonce per call, and is the only symmetric cipher this package ships. There is no compatibility shim — code still calling AddAesEncryption or constructing AesEncryption will not build; re-encrypt existing ciphertext under IAesGcmEncryption and switch the call sites.

⚙️ Configuration reference

There is no options type and no IConfiguration binding path in this package. The entire customisation surface is the four registration methods and the constructors behind them — everything a caller can vary is an argument, so this table is the equivalent of a configuration reference.

Registration surface (EncryptionSetup)

Method Argument Registers Lifetime Throws
AddEncryptionServices() — IShaHashing, IHmacHashing Transient —
AddAesGcmEncryption(base64Key) Base64 128/192/256-bit key IAesGcmEncryption Singleton ArgumentException on a blank key
AddRsaEncryption(privateKeyBase64) Base64 PKCS#1 private key IRsaEncryption Singleton ArgumentException on a blank key

All three skip registration when the service type is already registered, so a second call is a no-op rather than a second instance — the first call’s key is the one the application uses.

Constructor surface (when you build an instance yourself)

Constructor / factory Parameter Default Effect
new AesGcmEncryption(string? key = null) Base64 key, no : null null generates a random 256-bit key; a supplied key must decode to 16, 24, or 32 bytes.
new RsaEncryption(int keySize = 2048) Key size in bits 2048 Generates a fresh key pair at that size.
new RsaEncryption(string privateKeyBase64) PKCS#1 private key (required) Loads an existing pair; the public key is derived.
RsaEncryption.FromPublicKey(string publicKeyBase64) PKCS#1 public key (required) Public-only instance: Encrypt/Verify work, Decrypt/Sign throw.

Per-call switches

Member Parameter Default Effect
IAesGcmEncryption.EncryptString / DecryptString associatedData null Extra bytes bound into the authentication tag; a mismatch on decrypt throws CryptographicException.
IHmacHashing.ComputeSha256 / ComputeSha512 asBase64 true false returns upper-case hex instead of Base64.
IHmacHashing.VerifySha256 / VerifySha512 signatureIsBase64 true Must match how the signature was produced; a mismatch returns false.
IShaHashing.VerifySha256 / VerifySha512 ignoreCase true No effect — comparison runs on decoded bytes. (IHmacHashing’s Verify* overloads dropped this parameter entirely; it was never read.)
IShaHashing.ComputeSha256 / ComputeSha512 upperCase false true returns upper-case hex.

Fixed algorithm choices

These are not configurable and are worth knowing before you design around them:

Concern Value
AES-GCM nonce / tag 12 bytes / 16 bytes, fresh nonce per call
AES-GCM generated key size 256-bit
AES-GCM package layout Base64 of base64(nonce):base64(tag):base64(cipher)
RSA encryption padding OAEP-SHA256
RSA signature SHA-256 with PKCS#1 v1.5
Key export format Raw PKCS#1 DER, Base64 — not PEM
Verification comparison CryptographicOperations.FixedTimeEquals — also used for the AES-GCM base64Key overloads’ key check

🧱 Where it fits

Which registration you call decides which service you can resolve — and only the cipher registrations take a key:

Architecture diagram: AddEncryptionServices registers the transient IShaHashing and IHmacHashing services with no key; AddAesGcmEncryption and AddRsaEncryption each register one key-bearing singleton cipher inside a boundary labelled "you supply and persist the key"; Base64StringExtensions is static and never registered.

⚠️ Gotchas & limits