Mert Özen Further With Every Line
Security Cryptography for Developers — #09

Cryptography APIs from .NET Framework to .NET 11: Obsolete Types, New One-shot APIs and Reading Old Code

Mert Özen Nov 14, 2026 11 min 2 views
Cryptography APIs from .NET Framework to .NET 11: Obsolete Types, New One-shot APIs and Reading Old Code

When moving old crypto code to the new APIs, fixing the warnings isn't enough; unless you spell out the old constructors' hidden defaults (SHA-1, 1000 iterations, CBC), archived data won't open. With real outputs we cover the warnings from SYSLIB0021 to SYSLIB0060, behaviour-preserving migration, the APIs that arrived from .NET 5 to 10, and .NET 11's X25519, RFC 3394 AES Key Wrap, HMACSHA256.Verify, HPKE and composite ML-KEM additions.

Version note: The code in this post was built and run with .NET 10 and xUnit v3 4.0, and the .NET 11 sample with the .NET 11 SDK 11.0.100; the outputs were taken in Ubuntu 24.04 (OpenSSL 3.0) and Alpine 3.24 (OpenSSL 3.5) containers.

Guess First

For years Bookstore, the online bookstore used as the shared example across this site's series, sent its customer list to its accounting firm with a small export tool written for .NET Framework 4.x. The tool has been moved to .NET 10; it compiles and runs, it just gives five warnings. Someone on the team suggests silencing them with NoWarn, someone else suggests moving to the new APIs the warnings recommend. The second option looks cleaner, but there's a question: the archive holds export files encrypted years ago. Will they still open once the API changes?

They will, but only if you carry the old code's hidden defaults over knowingly. This is the last post of the series: with real build and run outputs, we cover reading old crypto code, the warnings of obsolete types, migrating while preserving behaviour, the APIs that arrived from .NET 5 to 10, and what came with .NET 11.

What You See in Old Code

The export tool as it was in the .NET Framework days is in the code's samples/Bookstore.LegacyCrypto folder. Shortened, it looks like this:

public static byte[] Export(string password, string csv)
{
    byte[] salt = new byte[16];
    using (RNGCryptoServiceProvider rng = new())
    {
        rng.GetBytes(salt);
    }

    using Rfc2898DeriveBytes kdf = new(password, salt);
    using RijndaelManaged aes = new() { KeySize = 256 };
    aes.Key = kdf.GetBytes(32);
    aes.GenerateIV();
    using ICryptoTransform encryptor = aes.CreateEncryptor();
    byte[] data = Encoding.UTF8.GetBytes(csv);
    byte[] ciphertext = encryptor.TransformFinalBlock(data, 0, data.Length);
    return [.. salt, .. aes.IV, .. ciphertext];
}

This project shows the warnings on purpose, which is why it isn't added to Bookstore.slnx; the series' other projects have TreatWarningsAsErrors on, so the same code wouldn't compile there:

$ dotnet build samples/Bookstore.LegacyCrypto
LegacyExportTool.cs(13,16): warning SYSLIB0023: 'RNGCryptoServiceProvider' is obsolete: 'RNGCryptoServiceProvider is obsolete. To generate a random number, use one of the RandomNumberGenerator static methods instead.' (https://aka.ms/dotnet-warnings/SYSLIB0023)
LegacyExportTool.cs(18,40): warning SYSLIB0060: 'Rfc2898DeriveBytes.Rfc2898DeriveBytes(string, byte[])' is obsolete: 'The constructors on Rfc2898DeriveBytes are obsolete. Use the static Pbkdf2 method instead.' (https://aka.ms/dotnet-warnings/SYSLIB0060)
LegacyExportTool.cs(19,15): warning SYSLIB0022: 'RijndaelManaged' is obsolete: 'The Rijndael and RijndaelManaged types are obsolete. Use Aes instead.' (https://aka.ms/dotnet-warnings/SYSLIB0022)
LegacyExportTool.cs(30,15): warning SYSLIB0021: 'SHA256Managed' is obsolete: 'Derived cryptographic types are obsolete. Use the Create method on the base type instead.' (https://aka.ms/dotnet-warnings/SYSLIB0021)
LegacyExportTool.cs(34,91): warning SYSLIB0057: 'X509Certificate2.X509Certificate2(byte[], string?)' is obsolete: 'Loading certificate data through the constructor or Import is obsolete. Use X509CertificateLoader instead to load certificates.' (https://aka.ms/dotnet-warnings/SYSLIB0057)

One detail: older sources list SYSLIB0041 for the Rfc2898DeriveBytes constructor. In .NET 7 that warning only marked the constructors that use the default hash and iteration count; .NET 10 made every constructor obsolete under SYSLIB0060, and SYSLIB0041 no longer exists in the .NET 10 runtime. Read warning numbers from the compiler's output, not from memory. The counterparts of the old types:

Old codeWarningToday's counterpart
SHA256Managed, *CryptoServiceProvider derived typesSYSLIB0021SHA256.HashData, Aes.Create()
RijndaelManagedSYSLIB0022Aes.Create(), one-shot EncryptCbc/DecryptCbc
RNGCryptoServiceProviderSYSLIB0023RandomNumberGenerator.GetBytes
new Rfc2898DeriveBytes(...)SYSLIB0060Rfc2898DeriveBytes.Pbkdf2
new X509Certificate2(byte[])SYSLIB0057X509CertificateLoader.LoadCertificate, LoadPkcs12
new AesGcm(key)SYSLIB0053new AesGcm(key, tagSizeInBytes)

Migrating While Preserving Behaviour

The trap in fixing the warnings is this: the old code uses values that were never written down. new Rfc2898DeriveBytes(password, salt) uses SHA-1 as the hash and 1000 iterations; RijndaelManaged defaults to CBC mode and PKCS#7 padding. If you don't spell these out when moving to the new API, the files in the archive won't open. We wrote a separate, small class for the read path:

// Reads customer export files written by the old .NET Framework export tool:
// salt(16) | iv(16) | AES-256-CBC ciphertext, key = PBKDF2-HMAC-SHA1, 1000 iterations
// (the defaults of the old Rfc2898DeriveBytes(password, salt) constructor).
// Read path only: nothing new is ever written in this format.
public static class LegacyExportCipher
{
    private const int LegacyIterations = 1000;

    public static byte[] Decrypt(string password, ReadOnlySpan<byte> file)
    {
        ReadOnlySpan<byte> salt = file[..16];
        ReadOnlySpan<byte> iv = file[16..32];
        byte[] key = Rfc2898DeriveBytes.Pbkdf2(password, salt, LegacyIterations, HashAlgorithmName.SHA1, 32);
        try
        {
            using Aes aes = Aes.Create();
            aes.Key = key;
            return aes.DecryptCbc(file[32..], iv);
        }
        finally
        {
            CryptographicOperations.ZeroMemory(key);
        }
    }
}

SHA-1 and 1000 iterations aren't acceptable values for a password today; current values for password storage are in the "OWASP A04: Cryptographic Failures (2)" post of the Web Security (OWASP Top 10) series. But the goal here isn't to protect new data, it's to read old data once and move it to a better format. The sample does this end to end: it produces a file the old way, reads it with the new APIs and re-encrypts the phone numbers into the versioned AES-GCM format from the third post:

$ dotnet run --project samples/Bookstore.LegacyCrypto
legacy export: 128 bytes, checksum BCDE64A17D5A59F7...
read with Rfc2898DeriveBytes.Pbkdf2 + Aes.DecryptCbc: 2 customers, first: 1001;Ada Lovelace;+90 555 010 1001
SHA256Managed == SHA256.HashData: True
customer 1001: v1 keyId 1, AQHBmiA49dcw2716IYp7...
customer 1002: v1 keyId 1, AQGpeF3g+h13bzm3n344...

We locked this equivalence in with a test: it produces a file with the old APIs, turning the warnings off with #pragma for those lines only, and checks that LegacyExportCipher reads it. The test also records a weakness of the old format: since CBC doesn't authenticate, a wrong password usually gives a padding error but can sometimes return meaningless text. In the new format the AES-GCM tag removes that ambiguity.

There's also a case you can't migrate: if the old code set RijndaelManaged's BlockSize to 256, that isn't AES. When I tried it on .NET 10 I got PlatformNotSupportedException: BlockSize must be 128 in this implementation.; to read such an archive you either open it once in an old .NET Framework environment and migrate it, or use a third-party library.

Crypto APIs Version by Version

When you read old code, the question "when did this API arrive?" comes up a lot. I verified the table below against the "what's new" pages on Microsoft Learn and by searching the crypto assemblies of the .NET 6, 7, 9, 10 and 11 runtimes I had at hand for the relevant type and warning names:

VersionWhat arrived
.NET 5 and 6One-shot static APIs: SHA256.HashData, HKDF, Rfc2898DeriveBytes.Pbkdf2, Aes.EncryptCbc, RandomNumberGenerator.GetBytes(int); SYSLIB0021, 0022, 0023
.NET 7SYSLIB0041: Rfc2898DeriveBytes constructors with the default hash and iteration count
.NET 8The SHA-3 family (SHA3_256.IsSupported), the tag size requirement in AesGcm (SYSLIB0053)
.NET 9KMAC, X509CertificateLoader and SYSLIB0057
.NET 10Post-quantum key encapsulation ML-KEM, hash-based signatures SLH-DSA, ML-DSA and composite ML-DSA; AES-KWP; PemEncoding.FindUtf8; the PKCS#12 export algorithm; search by SHA-256 thumbprint; SYSLIB0058 and SYSLIB0060
.NET 11X25519DiffieHellman, AES Key Wrap without padding, HMACSHA256.Verify, HPKE and composite ML-KEM (experimental), SYSLIB0064 and SYSLIB0065

A name appearing in a runtime isn't proof on its own that it arrived as public API in that version, so I read each row together with the release notes on Learn. I didn't have .NET 5 as a runtime to try, so I deliberately merged the first row as ".NET 5 and 6".

What Came with .NET 11

.NET 11 shipped on 10 November 2026; since the series stays on .NET 10 (LTS), the .NET 11 sample lives in a separate project: samples/Bookstore.Net11Crypto targets net11.0, needs the .NET 11 SDK, and isn't in the solution, so that dotnet build and dotnet test at the root keep working with the .NET 10 SDK. We put all the experiments in a single program, built it with the .NET 11 SDK and ran it with the .NET 11 runtime in an Alpine 3.24 container:

$ dotnet build samples/Bookstore.Net11Crypto
$ dotnet samples/Bookstore.Net11Crypto/bin/Debug/net11.0/Bookstore.Net11Crypto.dll
.NET 11.0.0, Alpine Linux v3.24
-- X25519DiffieHellman.IsSupported: True
public key 32 B (SPKI 44 B), shared secret 32 B, same on both sides: True
-- AES-KW: 1FA68B0A8112B447AEF34BD8FB5A7B829D3E862371D2CFE5 (RFC 3394 4.1: True)
19-byte key, padded (RFC 5649): 32 B
19-byte key, unpadded: ArgumentException
-- HMACSHA256.Verify: True, one byte changed: False
FixedTimeEquals(span, byte): [0x42] vs 0x42 True, [0x42, 0x42] vs 0x42 True, [0x41] vs 0x42 False
-- HPKE DHKEM_X25519_HKDF_SHA256: encapsulated 32 B, ciphertext 101 B for 85 B, opened: True
-- HPKE MLKEM_768: not supported here
-- HPKE MLKEM768_P256: not supported here
-- MLKEM768-X25519-SHA3-256: encapsulation key 1216 B, ciphertext 1120 B, secret 32 B, decapsulated equal: True

Line by line:

X25519DiffieHellman is the class we put off to "the last post" in the fourth and fifth posts of the series. Key agreement over Curve25519 is now in the BCL; the public key is 32 bytes, compare that with P-256's 91 bytes in SPKI form. There's still no Ed25519 signature in .NET 11; I scanned the public types of the .NET 11 runtime with reflection again and found no type named Ed25519 or EdDSA.

The unpadded AES Key Wrap implements RFC 3394, and the output matches the RFC's test vector 4.1 exactly. The unpadded version rejects a key that isn't a multiple of 8 with an ArgumentException; the padded AES-KWP we used in the third post wraps the same 19 bytes into 32. If an HSM or another system expects RFC 3394, you no longer need third-party code.

HMACSHA256.Verify brings the webhook verification from the second post down to one line and compares in constant time. The new FixedTimeEquals overload checks in constant time whether every byte of a span equals the given value; the two-byte line in the output shows that.

Hpke implements HPKE from RFC 9180 and is experimental under SYSLIB5009. In the fourth post we built our own envelope for the order file; HPKE does the same job with a standard format, and for the 85-byte order it adds only a 32-byte encapsulation and a 16-byte tag. The enum also has ML-KEM-based KEMs, but in this environment Hpke.IsSupported didn't return true for any KDF and AEAD combination of them. So the hybrid envelope from the eighth post stays in place for now.

CompositeMLKem combines ML-KEM with a classical key agreement in a single KEM and is experimental under SYSLIB5006. It offers what the eighth post's hybrid envelope does as a standard construction: a 1216-byte key, the same size as the X25519MLKEM768 key share in TLS.

There are also changes that don't show in the output but that you should know. MLDsa, fully experimental in .NET 10, is stable in .NET 11; only its pre-hash and external mu methods are still experimental. MLKem's SPKI methods are no longer experimental either. SlhDsa and CompositeMLDsa remain experimental; I checked this with reflection using the .NET 11 SDK. According to Microsoft Learn, EnvelopedCms can encrypt to ML-KEM recipients, but only on non-Windows platforms; I didn't try that. According to a compatibility note on Learn, in .NET 11 SslStream turns off certificate downloads through AIA by default when validating client certificates in the server role; our validator from the sixth post already did that with DisableCertificateDownloads. There are new obsoletions too: SYSLIB0064 marks the RSACryptoServiceProvider.Encrypt(byte[], bool) overload, and SYSLIB0065 marks the setter of AsnEncodedData.RawData.

A Migration Checklist

The order we followed when migrating Bookstore's export tool works for other old code as well:

  • Inventory first: which algorithm, which mode, which iteration count and which key source the code uses; write down the defaults too.
  • Don't silence the warnings, fix them; if you must suppress one, do it with #pragma on that line only, never with project-wide NoWarn.
  • Move the code that reads the old format into a separate class, use it only on the read path, and test the equivalence with data produced by the old API.
  • Write new data in a format that carries a version field and a keyId; re-encrypt old records in the background, and remove the old read path when that's done.
  • Check platform capability at startup, and run both Windows and your production Linux image in CI.

Production Notes

Code that reads an old format shouldn't live forever. Measure the progress of the re-encryption job: how many records are still in the old format, how many have moved. When the number reaches zero, delete LegacyExportCipher and its tests; otherwise a door stays open for a new file encrypted with SHA-1 and 1000 iterations to get into the system.

Before taking .NET 11's experimental APIs to production, factor in that they may change up to .NET 12. Put Hpke and CompositeMLKem behind a small interface of your own; a structure that picks by algorithm name, like the eighth post's EnvelopeReader, also turns the move from our own envelope to HPKE into an allow-list change.

Crypto APIs shouldn't decide between LTS and STS. Instead of moving to .NET 11 just for X25519DiffieHellman, if you need it, putting that code in a separate service or project keeps the main application on LTS.

Common Pitfalls

The first pitfall is the one from Guess First: losing the old code's defaults while fixing the warning. If you give Rfc2898DeriveBytes.Pbkdf2 SHA-256 and 600,000 iterations, your code looks "modern" but no file in the archive will open.

The second is the opposite mistake: keeping the old parameters for new data too. The read path knows the old parameters; the write path doesn't.

The third is turning off SYSLIB warnings project-wide with NoWarn. That way a new obsoletion that arrives later also disappears silently, like SYSLIB0064 in .NET 11.

The fourth is forgetting places that don't count as "crypto code": certificate loading, the TLS version setting, the HTTP client's validation callback. The settings from the sixth and seventh posts of the series are part of this inventory too.

The fifth is stating an API's version from memory. Expecting the SYSLIB0041 from older sources, the compiler gave SYSLIB0060; the right answer lies with the compiler and the release notes.

A Small Experiment

★☆☆ Add a method that uses new AesGcm(key) to the legacy sample and see the SYSLIB0053 warning. Then fix it with new AesGcm(key, 16).

★★☆ Write a "re-encryption" job for LegacyExportCipher: it should read the old export files in a folder, convert each line to the FieldEncryptor format, count its progress, and delete each file only after a successful migration. Test that a run interrupted halfway loses no data when it's run again.

★★★ Add an HPKE-based order envelope to samples/Bookstore.Net11Crypto and wire it into the eighth post's EnvelopeReader as a new algorithm name. Then encrypt the same order three ways, with the ECDH envelope, the hybrid ML-KEM envelope and HPKE, and compare the sizes.

We've reached the end of the series. Across nine posts we saw how to call the right primitive the right way: hashes, HMAC and HKDF; field encryption and envelope encryption with AES-GCM; asymmetric encryption and hybrid encryption; digital signatures and a signed license; X.509 and mTLS; the inside of TLS; the post-quantum transition and migrating old code. They all share one lesson: don't write the algorithm yourself, but know what you call, with which defaults you call it, and how you'll change it one day.