Skip to content

Cache AES one-shot cipher handles#129996

Open
vcsjones wants to merge 2 commits into
dotnet:mainfrom
vcsjones:cache-cipher-handles
Open

Cache AES one-shot cipher handles#129996
vcsjones wants to merge 2 commits into
dotnet:mainfrom
vcsjones:cache-cipher-handles

Conversation

@vcsjones

@vcsjones vcsjones commented Jun 29, 2026

Copy link
Copy Markdown
Member

This caches the handles used by the AES algorithm's one-shot handles so that repeated calls to {Encrypt,Decrypt}{Cbc,Ecb} can re-use their handles. This means any subsequent calls to these methods don't have to re-create the key handle and do key expansion.

Because the previous implementations were more or less thread-safe, but not intentionally, we add ConcurrencyBlock here. We do this prevent accidental misuse of these APIs concurrently. Even though they look "safe" because they are one-shots and don't mutate state, that is not the case with the underlying platform's implementations on each handle.

For example, in OpenSSL, it internally maintains the previous block of data during CBC processing because it needs to chain the blocks. Using the cipher concurrently is a sure way to lead to data corruption.

We do not do this for CFB because 1. CFB is a less common code and 2. the feedback size is part of the handle on some platforms. That means we would need a cached handle per operation, per feedback size. That makes the Aes class bigger with no clear benefit. We also do not do this for 3DES/DES/RC2 because these algorithms should no longer be used on any performance critical path.

Summarizing the benchmarks:

  1. "Single-use" calls to the one-shots will have a small allocation regression. This regression is a fixed amount and not dependent on the input size of the data. Because the Aes class itself is bigger now to hold these fields, allocating it increased its size by ~40 bytes.

    The use of ConcurrencyBlock introduces minimal overhead and appears to be within noise.

  2. Any additional calls to the methods that have been cached will see big improvements. Both in wall-clock time since the handle is being re-used, and allocations are down because they do not allocate the handles. In the right circumstances they are even allocation free.

Benchmark code: https://gist.github.com/vcsjones/f6f571224aff928d7e13d6b7ac3d404e
Benchmark results:

Method Job Length Mean Ratio RatioSD Allocated Alloc Ratio
EncryptCbc branch 16 498.9 ns 0.96 0.01 328 B 1.14
EncryptCbc main 16 519.0 ns 1.00 0.01 288 B 1.00
EncryptCbcTwice branch 16 558.1 ns 0.70 0.00 328 B 0.91
EncryptCbcTwice main 16 799.3 ns 1.00 0.00 360 B 1.00
DecryptCbc branch 16 507.2 ns 0.97 0.01 328 B 1.14
DecryptCbc main 16 524.7 ns 1.00 0.01 288 B 1.00
DecryptCbcTwice branch 16 575.1 ns 0.70 0.00 328 B 0.91
DecryptCbcTwice main 16 825.9 ns 1.00 0.00 360 B 1.00
EncryptEcb branch 16 507.7 ns 1.04 0.03 328 B 1.14
EncryptEcb main 16 486.7 ns 1.00 0.01 288 B 1.00
EncryptEcbTwice branch 16 566.0 ns 0.75 0.01 328 B 0.91
EncryptEcbTwice main 16 758.6 ns 1.00 0.01 360 B 1.00
DecryptEcb branch 16 528.4 ns 1.06 0.01 328 B 1.14
DecryptEcb main 16 498.8 ns 1.00 0.02 288 B 1.00
DecryptEcbTwice branch 16 574.7 ns 0.74 0.00 328 B 0.91
DecryptEcbTwice main 16 772.8 ns 1.00 0.00 360 B 1.00
EncryptCbc branch 32 557.7 ns 1.09 0.01 328 B 1.14
EncryptCbc main 32 511.3 ns 1.00 0.01 288 B 1.00
EncryptCbcTwice branch 32 614.1 ns 0.74 0.01 328 B 0.91
EncryptCbcTwice main 32 825.0 ns 1.00 0.00 360 B 1.00
DecryptCbc branch 32 553.9 ns 1.07 0.01 328 B 1.14
DecryptCbc main 32 516.5 ns 1.00 0.01 288 B 1.00
DecryptCbcTwice branch 32 620.9 ns 0.74 0.00 328 B 0.91
DecryptCbcTwice main 32 842.8 ns 1.00 0.00 360 B 1.00
EncryptEcb branch 32 512.3 ns 1.04 0.01 328 B 1.14
EncryptEcb main 32 493.0 ns 1.00 0.01 288 B 1.00
EncryptEcbTwice branch 32 571.5 ns 0.75 0.01 328 B 0.91
EncryptEcbTwice main 32 759.0 ns 1.00 0.00 360 B 1.00
DecryptEcb branch 32 516.9 ns 1.03 0.01 328 B 1.14
DecryptEcb main 32 500.0 ns 1.00 0.00 288 B 1.00
DecryptEcbTwice branch 32 584.8 ns 0.75 0.00 328 B 0.91
DecryptEcbTwice main 32 777.8 ns 1.00 0.00 360 B 1.00
EncryptCbc branch 64 564.7 ns 1.05 0.01 328 B 1.14
EncryptCbc main 64 536.6 ns 1.00 0.01 288 B 1.00
EncryptCbcTwice branch 64 671.0 ns 0.77 0.00 328 B 0.91
EncryptCbcTwice main 64 870.1 ns 1.00 0.00 360 B 1.00
DecryptCbc branch 64 543.9 ns 1.04 0.01 328 B 1.14
DecryptCbc main 64 521.1 ns 1.00 0.00 288 B 1.00
DecryptCbcTwice branch 64 618.4 ns 0.74 0.00 328 B 0.91
DecryptCbcTwice main 64 836.9 ns 1.00 0.00 360 B 1.00
EncryptEcb branch 64 512.9 ns 1.06 0.01 328 B 1.14
EncryptEcb main 64 485.4 ns 1.00 0.00 288 B 1.00
EncryptEcbTwice branch 64 564.9 ns 0.74 0.01 328 B 0.91
EncryptEcbTwice main 64 761.9 ns 1.00 0.00 360 B 1.00
DecryptEcb branch 64 522.5 ns 1.06 0.01 328 B 1.14
DecryptEcb main 64 493.3 ns 1.00 0.01 288 B 1.00
DecryptEcbTwice branch 64 584.2 ns 0.76 0.00 328 B 0.91
DecryptEcbTwice main 64 770.6 ns 1.00 0.00 360 B 1.00
EncryptCbc branch 128 611.3 ns 0.97 0.04 328 B 1.14
EncryptCbc main 128 632.0 ns 1.00 0.07 288 B 1.00
EncryptCbcTwice branch 128 770.6 ns 0.78 0.00 328 B 0.91
EncryptCbcTwice main 128 989.4 ns 1.00 0.00 360 B 1.00
DecryptCbc branch 128 539.2 ns 1.02 0.01 328 B 1.14
DecryptCbc main 128 526.6 ns 1.00 0.01 288 B 1.00
DecryptCbcTwice branch 128 607.0 ns 0.72 0.00 328 B 0.91
DecryptCbcTwice main 128 838.3 ns 1.00 0.00 360 B 1.00
EncryptEcb branch 128 497.3 ns 1.02 0.01 328 B 1.14
EncryptEcb main 128 485.8 ns 1.00 0.01 288 B 1.00
EncryptEcbTwice branch 128 570.3 ns 0.73 0.00 328 B 0.91
EncryptEcbTwice main 128 783.9 ns 1.00 0.00 360 B 1.00
DecryptEcb branch 128 546.2 ns 1.02 0.09 328 B 1.14
DecryptEcb main 128 533.0 ns 1.00 0.01 288 B 1.00
DecryptEcbTwice branch 128 578.0 ns 0.74 0.00 328 B 0.91
DecryptEcbTwice main 128 782.9 ns 1.00 0.00 360 B 1.00
EncryptCbc branch 1024 1,474.7 ns 1.04 0.00 328 B 1.14
EncryptCbc main 1024 1,419.7 ns 1.00 0.00 288 B 1.00
EncryptCbcTwice branch 1024 2,503.5 ns 0.94 0.00 328 B 0.91
EncryptCbcTwice main 1024 2,651.7 ns 1.00 0.00 360 B 1.00
DecryptCbc branch 1024 624.8 ns 1.07 0.01 328 B 1.14
DecryptCbc main 1024 581.4 ns 1.00 0.00 288 B 1.00
DecryptCbcTwice branch 1024 745.3 ns 0.76 0.00 328 B 0.91
DecryptCbcTwice main 1024 978.6 ns 1.00 0.00 360 B 1.00
EncryptEcb branch 1024 595.3 ns 1.02 0.00 328 B 1.14
EncryptEcb main 1024 583.9 ns 1.00 0.00 288 B 1.00
EncryptEcbTwice branch 1024 764.7 ns 0.76 0.00 328 B 0.91
EncryptEcbTwice main 1024 1,003.0 ns 1.00 0.00 360 B 1.00
DecryptEcb branch 1024 594.8 ns 1.02 0.02 328 B 1.14
DecryptEcb main 1024 585.6 ns 1.00 0.03 288 B 1.00
DecryptEcbTwice branch 1024 763.0 ns 0.78 0.00 328 B 0.91
DecryptEcbTwice main 1024 972.6 ns 1.00 0.00 360 B 1.00
EncryptCbc branch 16384 16,014.2 ns 1.01 0.00 328 B 1.14
EncryptCbc main 16384 15,921.5 ns 1.00 0.00 288 B 1.00
EncryptCbcTwice branch 16384 31,470.4 ns 0.99 0.00 328 B 0.91
EncryptCbcTwice main 16384 31,773.2 ns 1.00 0.00 360 B 1.00
DecryptCbc branch 16384 1,931.8 ns 1.01 0.00 328 B 1.14
DecryptCbc main 16384 1,912.4 ns 1.00 0.00 288 B 1.00
DecryptCbcTwice branch 16384 3,398.7 ns 0.94 0.00 328 B 0.91
DecryptCbcTwice main 16384 3,631.9 ns 1.00 0.00 360 B 1.00
EncryptEcb branch 16384 2,515.3 ns 1.00 0.00 328 B 1.14
EncryptEcb main 16384 2,524.4 ns 1.00 0.01 288 B 1.00
EncryptEcbTwice branch 16384 4,585.5 ns 0.95 0.00 328 B 0.91
EncryptEcbTwice main 16384 4,849.7 ns 1.00 0.00 360 B 1.00
DecryptEcb branch 16384 2,208.6 ns 1.00 0.01 328 B 1.14
DecryptEcb main 16384 2,200.0 ns 1.00 0.01 288 B 1.00
DecryptEcbTwice branch 16384 4,008.6 ns 0.94 0.00 328 B 0.91
DecryptEcbTwice main 16384 4,277.2 ns 1.00 0.00 360 B 1.00

Reuse ECB and CBC lite ciphers for repeated AES one-shot operations while guarding one-shot and key mutation paths with ConcurrencyBlock. Clear cached ciphers when the key changes or the instance is disposed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @bartonjs, @vcsjones, @dotnet/area-system-security
See info in area-owners.md if you want to be subscribed.

if (_keyBox is null)
{
GenerateKey();
Span<byte> key = stackalloc byte[KeySize / BitsPerByte];

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't use GenerateKey() here because ConcurrencyBlock is not re-entrant. GetKey can be called while a ConcurrencyBlock is already being held and GenerateKey is also under a ConcurrencyBlock. We could have a GenerateKeyUnchecked or similar but given that's is 2 lines of code seemed not worth it.

encrypting: false));

using (cipher)
using (ConcurrencyBlock.Enter(ref _block))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even though CFB is not cached it felt odd to leave it out of the ConcurrencyBlock. We can take it out but I want to more or less make it official: don't call these things concurrently.

private ILiteSymmetricCipher? _decryptEcbCipher;
private ILiteSymmetricCipher? _encryptCbcCipher;
private ILiteSymmetricCipher? _decryptCbcCipher;
private ConcurrencyBlock _block;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use a single ConcurrencyBlock for everything. In theory mix-and-matching concurrent calls to EncryptCbc and DecryptCbc would "work" if we had a per-handle block but that is just making the intentions less clear. I want to avoid "Sometimes concurrency is okay, sometimes it isn't" and just go for "it isn't".

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the internal AesImplementation used by Aes.Create() to cache and reuse ILiteSymmetricCipher handles for AES one-shot ECB/CBC encrypt/decrypt operations, and introduces ConcurrencyBlock to reject concurrent use on the same Aes instance.

Changes:

  • Cache ECB/CBC one-shot cipher handles (_encrypt* / _decrypt*) and reuse them via Reset(iv) (GetOrCreateCachedLiteCipher).
  • Add ConcurrencyBlock to serialize one-shot operations and key mutation (KeySize, SetKeyCore, one-shot cores), plus cache clearing on key changes/dispose.
  • Refactor key initialization to generate key material directly in GetKey().
Show a summary per file
File Description
src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/AesImplementation.cs Adds cached one-shot cipher handle reuse and concurrency blocking; refactors key initialization and cache invalidation.

Copilot's findings

  • Files reviewed: 1/1 changed files
  • Comments generated: 2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants