A Cryptography Map for Developers: Confidentiality, Integrity, Authentication and .NET's Provider Model
In crypto code the starting point is the goal, not the algorithm: confidentiality needs encryption, integrity a MAC, non-repudiation a digital signature, and .NET takes these primitives from the operating system's library (CNG, OpenSSL, Apple). We map goals to primitives and .NET types, check platform support at startup with IsSupported, and show the one-shot APIs, FixedTimeEquals and ZeroMemory with real outputs.
Version note: The code in this post was built and run with .NET 10 and xUnit v3 4.0; the platform outputs were taken in Ubuntu 24.04 (OpenSSL 3.0) and Alpine 3.24 (OpenSSL 3.5) containers.
Guess First
The lines below come from the same .NET 10 application, from the same build output. The only difference is the container image the application runs in:
Ubuntu 24.04.5 LTS, OpenSSL 3.0.13
ML-KEM no
Alpine Linux v3.24, OpenSSL 3.5.9
ML-KEM yes
Same code, same runtime, no NuGet package. How can an algorithm exist on one machine and not on the other? If you know the answer, half of this post will feel familiar; if you don't, this is the piece of knowledge that will help you most in the rest of the series. .NET's crypto classes don't compute the algorithms themselves, they hand the work to the operating system's crypto library. In this post we first map which security goal is met by which primitive, then look at where .NET gets those primitives from, how to ask about platform differences in code, and two helpers you'll meet again in every piece of crypto code.
The difference between encoding, hashing and encryption, Random versus RandomNumberGenerator, why MD5 and SHA-1 were dropped, and HTTPS with HSTS are covered in the "OWASP A04: Cryptographic Failures (1)" post of the Web Security (OWASP Top 10) series; password storage and an introduction to Data Protection are in that series' second crypto post. We don't repeat them here; we continue from there.
Security Goals and Primitives
When you write crypto code, the first question shouldn't be "which algorithm should I use?" but "what am I trying to protect?" There are four goals, and each is met by a different tool:
- Confidentiality: only someone with the key can read the data. Tool: encryption.
- Integrity: if the data changed in transit or on disk, you notice. Tool: a hash alone is not enough, you need a keyed MAC, a short value proving the message was produced by someone who knows the key.
- Authenticity: you can prove the data came from the party you expect. Tool: a MAC (when the parties share a secret) or a digital signature.
- Non-repudiation: the signer can't later say "I didn't produce this". Tool: only a digital signature, because with a MAC the verifying party can produce the same value with the same key.
Mapping these four goals to .NET types gives us the map of the series. The abbreviations in the table will come up throughout: SHA and SHAKE are the standard hash families, HMAC and KMAC are MACs built on SHA-2 and SHA-3, HKDF derives keys from a key, AEAD modes such as AES-GCM encrypt and check integrity at the same time, RSA-OAEP and ECDH distribute keys, RSA-PSS and ECDSA sign, and ML-KEM, ML-DSA and SLH-DSA are the new key-establishment and signature algorithms designed to resist quantum computers.
| Goal | Primitive | .NET type | Post |
|---|---|---|---|
| Integrity (keyless, check only) | Hash: SHA-2, SHA-3 | SHA256, SHA3_256 | 02 |
| Integrity + origin (shared secret) | MAC: HMAC, KMAC | HMACSHA256, Kmac256 | 02 |
| Deriving subkeys from a key | KDF: HKDF | HKDF | 02 |
| Confidentiality + integrity | AEAD: AES-GCM, ChaCha20-Poly1305 | AesGcm, ChaCha20Poly1305 | 03 |
| Wrapping a key | AES Key Wrap with Padding | Aes.EncryptKeyWrapPadded | 03 |
| Key distribution | RSA-OAEP, ECDH | RSA, ECDiffieHellman | 04 |
| Origin + non-repudiation | Digital signature: RSA-PSS, ECDSA | RSA, ECDsa | 05 |
| Binding a public key to its owner | X.509 certificate, chain | X509Certificate2, X509Chain | 06 |
| Data in transit | TLS | SslStream | 07 |
| Post-quantum keys and signatures | ML-KEM, ML-DSA, SLH-DSA | MLKem, MLDsa, SlhDsa | 08 |
The table has a side effect: when you approach a goal with the wrong tool, the mistake doesn't show itself right away. Code that "signs" a webhook with plain SHA-256 runs and the tests pass; what's missing is that anyone can compute the hash. Code that encrypts a field with AES-CBC runs too; what's missing is integrity. Each post in the series takes one row of the table and answers "what does the correct call look like, and how do you spot the wrong one?"
Kerckhoffs's Principle: Don't Write Your Own Crypto, but Know What You Call
In his 1883 articles on military cryptography, Auguste Kerckhoffs argued that a system's security should depend only on keeping the key secret, not on keeping the algorithm secret. Today every detail of AES, RSA and TLS is public and their security doesn't suffer from it; on the contrary, they were studied for years because they're public. For a developer this translates into two rules.
First, don't write your own algorithm, your own mode or your own "scrambling" function. Every piece of code that starts with "let's mix the bits with a fixed value and Base64 it" ties its secrecy to the code staying secret; the moment the code goes through a decompiler, the secrecy is gone. Second, and less talked about: calling a ready-made primitive isn't enough on its own. AesGcm is a correct primitive, but if you use the same nonce twice with the same key you lose both confidentiality and integrity. RSA is a correct primitive, but pick the wrong padding and you stay open to a known class of attacks. The mistakes are mostly not in the algorithm but in the calling code: where the key comes from, how the nonce is generated, how the result is compared, which bytes are signed.
This series focuses on the second rule. We go into the math only to explain why a parameter matters; the real job is calling the right primitive with the right parameters and proving it with a test.
.NET's Provider Model: Same Code, Different Library
.NET (Core and .NET 5 onwards) doesn't implement crypto algorithms itself. Classes such as Aes, RSA and SHA256 are a thin layer over the platform's crypto library: CNG on Windows, the installed OpenSSL on Linux, Apple's libraries on macOS. This approach has two benefits: security patches arrive with operating system updates, and if the operating system's library is FIPS validated, the application uses validated algorithms too. The price: the application can only use the algorithms the platform supports.
Let's look at what the factory methods return:
public static void Providers()
{
using RSA rsa = RSA.Create(2048);
using Aes aes = Aes.Create();
using SHA256 sha256 = SHA256.Create();
Console.WriteLine(rsa.GetType().FullName);
Console.WriteLine(aes.GetType().FullName);
Console.WriteLine(sha256.GetType().FullName);
// Platform-specific type: without this guard the CA1416 analyzer breaks the build
if (OperatingSystem.IsWindows())
{
using RSACng cng = new(2048);
Console.WriteLine($"RSACng, provider: {cng.Key.Provider}");
}
else
{
Console.WriteLine("RSACng: Windows only, skipped");
}
}
$ dotnet run --project src/Bookstore.CryptoLab -- providers
System.Security.Cryptography.RSAWrapper
System.Security.Cryptography.AesImplementation
System.Security.Cryptography.SHA256+Implementation
RSACng: Windows only, skipped
On Linux, RSA.Create() returns an RSAWrapper with an RSAOpenSsl inside that talks to OpenSSL. On Windows the same line returns a CNG-based implementation. The "Cross-platform cryptography" page on Microsoft Learn says plainly that this is an implementation detail and may change. The takeaway for your code is simple: always keep the variable in the abstract type (RSA, Aes, ECDsa) and create the object with the factory method.
Platform-specific types such as RSACng and ECDsaOpenSsl only make sense when you need native interop, for example opening a CNG key container on Windows. Remove the guard and the compiler warns you; this project has TreatWarningsAsErrors on, so the build breaks:
error CA1416: This call site is reachable on all platforms. 'RSACng' is only supported on: 'windows'.
Don't silence this analyzer; the OperatingSystem.IsWindows() guard satisfies it and forces you to write down what the code does on the other platforms.
One more note: newer primitives such as AES-GCM and Shake128 don't use this inheritance model; they're sealed classes. If you want your own abstraction, as the documentation also says, that layer is yours to write. That's exactly what we'll do later in the series: the Bookstore.Crypto library will offer the rest of the application intent-level methods such as "encrypt the field" and "verify the webhook".
IsSupported: Asking About Platform Support in Code
Every algorithm that may depend on the platform has a static IsSupported property. We add a small probe to Bookstore's crypto library that collects them in one place. Bookstore, the online bookstore used as the shared example across this site's series, gets three new projects in this series: the Bookstore.Crypto library, the Bookstore.CryptoLab console app that runs the experiments, and the Bookstore.Crypto.Tests test project.
public sealed record CryptoCapability(string Name, bool IsSupported);
public static class CryptoCapabilities
{
public static IReadOnlyList<CryptoCapability> Probe() =>
[
new("AES-GCM", AesGcm.IsSupported),
new("AES-CCM", AesCcm.IsSupported),
new("ChaCha20-Poly1305", ChaCha20Poly1305.IsSupported),
new("SHA3-256", SHA3_256.IsSupported),
new("SHAKE128", Shake128.IsSupported),
new("KMAC256", Kmac256.IsSupported),
new("ML-KEM", MLKem.IsSupported),
#pragma warning disable SYSLIB5006 // MLDsa and SlhDsa are [Experimental] in .NET 10
new("ML-DSA", MLDsa.IsSupported),
new("SLH-DSA", SlhDsa.IsSupported),
#pragma warning restore SYSLIB5006
];
public static string DescribePlatform()
{
string os = RuntimeInformation.OSDescription;
if (!OperatingSystem.IsLinux())
{
return os;
}
// OpenSSL 3 version number layout: 0xMNN00PP0
long v = SafeEvpPKeyHandle.OpenSslVersion;
return $"{os}, OpenSSL {v >> 28}.{(v >> 20) & 0xFF}.{(v >> 4) & 0xFF}";
}
}
MLDsa and SlhDsa are marked [Experimental] in .NET 10; using them means consciously suppressing the SYSLIB5006 warning. We'll open up the post-quantum side in the eighth post of the series; here they're in the list only to show the platform difference.
We publish the application once and run the same output in two different Linux images. On Windows the same commands work in PowerShell with Docker Desktop:
$ dotnet publish src/Bookstore.CryptoLab -c Release -o out
$ docker run --rm -v "${PWD}/out:/app" -w /app mcr.microsoft.com/dotnet/runtime:10.0-noble dotnet Bookstore.CryptoLab.dll capabilities
Ubuntu 24.04.5 LTS, OpenSSL 3.0.13
AES-GCM yes
AES-CCM yes
ChaCha20-Poly1305 yes
SHA3-256 yes
SHAKE128 yes
KMAC256 yes
ML-KEM no
ML-DSA no
SLH-DSA no
Bookstore requirements: OK
$ docker run --rm -v "${PWD}/out:/app" -w /app mcr.microsoft.com/dotnet/runtime:10.0-alpine dotnet Bookstore.CryptoLab.dll capabilities
Alpine Linux v3.24, OpenSSL 3.5.9
AES-GCM yes
AES-CCM yes
ChaCha20-Poly1305 yes
SHA3-256 yes
SHAKE128 yes
KMAC256 yes
ML-KEM yes
ML-DSA yes
SLH-DSA yes
Bookstore requirements: OK
That's the answer to the opening question: the post-quantum algorithms arrived with OpenSSL 3.5. The Ubuntu 24.04 image ships OpenSSL 3.0, so there's no ML-KEM; the Alpine 3.24 image ships OpenSSL 3.5, so there is. Same build output, different base image, different capability. Switching the base image in a Dockerfile "to make it smaller" can silently change what the application can do cryptographically.
I had no way to run this on Windows, so instead of an output I'm passing on the "Cross-platform cryptography" table from Microsoft Learn. SHA-3 and HMAC-SHA3 need Windows 11 build 25324 or later, KMAC needs build 26016 or later, ChaCha20-Poly1305 needs Windows 10 build 20142 or later, and for the post-quantum algorithms the table points to current Windows 11 releases with post-quantum support. On macOS there's no SHA-3, KMAC or AES-CCM at all (the path that ran AES-CCM through OpenSSL on macOS was removed in .NET 10). On your own Windows machine you can run dotnet run --project src/Bookstore.CryptoLab -- capabilities and compare the result with that table.
The probe's real use is at application startup. AES-GCM is the one thing Bookstore can't do without in any environment; if it's missing, we want to fail when the process starts, not on the first customer record:
public static class CryptoRequirements
{
// Algorithms Bookstore cannot run without, on any platform
public static readonly string[] Bookstore = ["AES-GCM"];
public static void EnsureSupported(IEnumerable<CryptoCapability> capabilities, IEnumerable<string> required)
{
HashSet<string> supported = capabilities.Where(c => c.IsSupported).Select(c => c.Name).ToHashSet();
string[] missing = required.Where(name => !supported.Contains(name)).ToArray();
if (missing.Length > 0)
{
throw new PlatformNotSupportedException(
$"Missing cryptographic algorithms: {string.Join(", ", missing)}. Platform: {CryptoCapabilities.DescribePlatform()}");
}
}
}
Its test verifies that the name of an unsupported algorithm shows up in the error message. Taking the probe as a parameter lets the test set up the "no ML-KEM" case without switching containers:
[Fact]
public void Missing_algorithm_fails_at_startup_with_its_name()
{
CryptoCapability[] capabilities = [new("AES-GCM", true), new("ML-KEM", false)];
PlatformNotSupportedException ex = Assert.Throws<PlatformNotSupportedException>(
() => CryptoRequirements.EnsureSupported(capabilities, ["AES-GCM", "ML-KEM"]));
Assert.Contains("ML-KEM", ex.Message);
Assert.DoesNotContain("AES-GCM,", ex.Message);
}
One-shot APIs and the Old Instance APIs
The shape you see most often in older code: create an instance with SHA256.Create(), call ComputeHash, then dispose it. .NET 5 brought static one-shot methods for hashing and HMAC: SHA256.HashData, HMACSHA256.HashData. The documentation describes them as easier to use, lower on allocations, thread safe, and picking the best implementation for the platform. Let's measure the allocation difference. The sample data is an ISBN:
public static void HashApi()
{
byte[] isbn = Encoding.UTF8.GetBytes("978-0441013593");
const int Iterations = 100_000;
// Old style: create an instance every time, call ComputeHash that returns a byte[]
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < Iterations; i++)
{
using SHA256 sha = SHA256.Create();
_ = sha.ComputeHash(isbn);
}
long instanceBytes = GC.GetAllocatedBytesForCurrentThread() - before;
// One-shot: static method, the result goes into a stack buffer
before = GC.GetAllocatedBytesForCurrentThread();
Span<byte> digest = stackalloc byte[SHA256.HashSizeInBytes];
for (int i = 0; i < Iterations; i++)
{
SHA256.HashData(isbn, digest);
}
long oneShotBytes = GC.GetAllocatedBytesForCurrentThread() - before;
Console.WriteLine($"instance + ComputeHash : {instanceBytes / Iterations,4} bytes/hash");
Console.WriteLine($"SHA256.HashData (span) : {oneShotBytes / Iterations,4} bytes/hash");
Console.WriteLine(Convert.ToHexStringLower(digest));
}
$ dotnet run -c Release --project src/Bookstore.CryptoLab -- hash-api
instance + ComputeHash : 248 bytes/hash
SHA256.HashData (span) : 0 bytes/hash
90a07a9c3ca9e1fe92d719e36c5e42469c23e099440d2b9333e940587e3cc6c0
Same hash; the difference is 248 bytes against zero allocations per call. On an endpoint that verifies a few thousand webhooks per second, that shows up directly as extra work for the garbage collector. The bigger difference is thread safety: SHA256 and HMACSHA256 instances are not thread safe. Code that registers an instance as a singleton in the DI container and shares it across parallel requests both throws exceptions under load and, worse, produces wrong hashes without any error; that's very hard to see in a low-traffic test environment. One-shot methods have no shared state, so this class of bug disappears.
The instance APIs still have their place: IncrementalHash when data arrives in pieces, SHA256.HashData(Stream) or HashDataAsync when hashing a Stream. AES's older, non-AEAD modes now have one-shot methods like aes.EncryptCbc(...) instead of CryptoStream. The rule: one-shot for a single block of bytes, the incremental or stream overload for a stream; "Create() on every call" belongs only in old code.
FixedTimeEquals and ZeroMemory
The System.Security.Cryptography.CryptographicOperations class has two methods we'll use in almost every post of the series.
FixedTimeEquals compares two byte sequences in a time that depends only on their length, not their content. A normal comparison (SequenceEqual, a for loop, or == on hex strings) stops at the first differing byte. If you compare an incoming MAC against the expected value that way, the response time leaks how many bytes of the expected value matched. Network noise makes that leak harder to use, but we don't want to leave security to noise. In Bookstore we wrap it in one place:
public static class Tags
{
// Compares the expected and received tag without stopping at the first differing byte
public static bool AreEqual(ReadOnlySpan<byte> expected, ReadOnlySpan<byte> actual) =>
CryptographicOperations.FixedTimeEquals(expected, actual);
}
[Fact]
public void One_flipped_bit_or_a_shorter_tag_does_not_match()
{
byte[] tag = RandomNumberGenerator.GetBytes(32);
byte[] flipped = tag.ToArray();
flipped[31] ^= 0x01;
Assert.False(Tags.AreEqual(tag, flipped));
Assert.False(Tags.AreEqual(tag, tag.AsSpan(0, 16)));
}
The second assert matters: two sequences of different length are always false. Code that truncates its expected value to the length of the incoming signature before comparing lets an attacker send a short tag; always compare at full length.
ZeroMemory clears a buffer and doesn't let the compiler or the JIT skip the write because "nothing reads it afterwards". It's used to wipe key material from memory when you're done with it:
public static void Zeroize()
{
// Pinned array: the GC will not move it, so no stray copies are left behind
byte[] key = GC.AllocateArray<byte>(32, pinned: true);
RandomNumberGenerator.Fill(key);
try
{
Console.WriteLine($"in use : {Convert.ToHexStringLower(key.AsSpan(0, 8))}...");
// ... encrypt or sign with the key here
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
Console.WriteLine($"cleared: {Convert.ToHexStringLower(key.AsSpan(0, 8))}...");
}
$ dotnet run --project src/Bookstore.CryptoLab -- zeroize
in use : 1f1bf24efdb0bd12...
cleared: 0000000000000000...
Set the right expectation: this is a hardening step, not a guarantee. The GC can move an ordinary array during compaction and leave a copy at the old address; a pinned array prevents that. A secret held in a string can never be wiped because strings are immutable; that's one reason to carry keys as byte[] or Span<byte> rather than string. Classes such as AesGcm copy the key to the native side and clear it in Dispose, so use them with using.
What We'll Build in Bookstore Across the Series
We tie every row of the map to a real job in Bookstore. The posts are ordered so that one post's tool is used in the next:
In the second post we verify the HMAC signature on the webhook from the payment provider and derive purpose-specific subkeys from a single master key with HKDF. In the third we encrypt the Customer.Phone field with AES-GCM, bind the ciphertext to the customer record, and set up envelope encryption and key rotation. In the fourth we encrypt the purchase order file sent to the supplier with hybrid encryption so that only the supplier can open it. In the fifth we produce a signed license file for the offline installation used by institutional customers such as schools and libraries, and verify it inside the application with a public key.
In the later posts we load X.509 certificates, do chain validation in code and set up mTLS between Bookstore.Api and the Ordering service; then we look at TLS's key schedule and record layer, the post-quantum transition and, finally, how the crypto APIs evolved from .NET Framework to today.
Production Notes
The base image is a crypto decision. Ubuntu, Alpine, Azure Linux and chiseled images ship different OpenSSL versions, and OpenSSL's minor version can decide whether an algorithm exists. Print the capability output in CI on every pull request that changes the base image; the capabilities command above is enough for that. Likewise, on Windows servers support is decided by the Windows build number, not the .NET version.
If a required algorithm is missing, the application should fail at startup, not during a request. Calling EnsureSupported in Program.cs before builder.Build() means a pod started with the wrong image in Kubernetes never becomes ready. For an optional algorithm (for example "ML-KEM if available, classic otherwise") make the choice once at startup and log it; don't ask again on every request.
In environments that need FIPS compliance, remember that .NET does nothing on its own: compliance comes from the underlying operating system library running in FIPS mode. In FIPS mode some algorithms, MD5 for example, fail; test that in staging with the same image and OS settings as production.
On the measurement side, tracking crypto call durations as a separate metric is unnecessary in most applications; but turning every Create() + ComputeHash pair on a hot path into a one-shot call is a cheap win in allocations.
Common Pitfalls
The first pitfall is binding to a platform-specific type. Code written with new RSACryptoServiceProvider() or new AesCng() runs on Windows for years and, the day it moves to a Linux container, throws PlatformNotSupportedException or, worse, runs with an old and limited behaviour. Use the abstract type and the factory method.
The second is checking IsSupported on the developer machine but not in production. Your Windows 11 has SHA-3; the customer's Windows Server may not. Do the check in code, at startup.
The third is comparing MACs or signatures as hex strings: expectedHex == receivedHex. It leaks timing and also brings encoding problems such as upper versus lower case. Convert the incoming value to bytes and compare with FixedTimeEquals.
The fourth is sharing crypto objects. Keeping an HMACSHA256 instance in a static field and using it from several threads leads to exceptions and, more dangerously, silently wrong results. Use the one-shot method or create a new instance per operation.
The fifth is confusing the goal with the primitive: a plain hash for integrity, a non-AEAD mode for confidentiality, HMAC for non-repudiation. Find the row in the table first, then write the code.
A Small Experiment
★☆☆ Run the capabilities command on your own Windows machine. What do the SHA-3 and ML-KEM rows show? Compare the result with your Windows build number (winver) and the table on Microsoft Learn.
★★☆ Add "ML-KEM" to the CryptoRequirements.Bookstore list and run the application in the Ubuntu image. What does the error message tell you, and with what platform information? Then restore the list and think about a design that separates "required" from "optional" algorithms: write a method that logs the algorithm chosen at startup for the optional ones.
★★★ Write a command that shares an HMACSHA256 instance in a static field and computes the MAC of 100,000 different messages with Parallel.For; compare the results with the one-shot HMACSHA256.HashData and count how many are wrong. Then repeat the test with the one-shot method.
In the next post we get into the first three rows of the table: which properties a hash guarantees, why SHA256(secret + message) isn't a signature, verifying Bookstore's payment webhook with HMAC, and deriving purpose-specific keys from a single master key with HKDF.