-
Notifications
You must be signed in to change notification settings - Fork 316
Performance | Introduce lower-allocation AlwaysEncrypted primitives #3554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
paulmedynski
merged 8 commits into
dotnet:main
from
edwardneal:perf/alwaysencrypted-primitives
Sep 10, 2025
Merged
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a9308cf
Introduce low-allocation AlwaysEncrypted primitives
edwardneal e30ccae
Respond to code review
edwardneal ba8bdea
Subsequent code review response
edwardneal 7defd2f
Typo correction
edwardneal a311495
Review and add exceptions to XML documentation
edwardneal 7990d0a
Merge main
edwardneal cb143aa
Add additional instances where CryptographicException can be thrown
edwardneal dc50d06
Merge branch 'main' into perf/alwaysencrypted-primitives
edwardneal File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
114 changes: 114 additions & 0 deletions
114
...ft.Data.SqlClient/src/Microsoft/Data/SqlClient/AlwaysEncrypted/ColumnMasterKeyMetadata.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
// See the LICENSE file in the project root for more information. | ||
|
||
using System; | ||
using System.Runtime.CompilerServices; | ||
using System.Security.Cryptography; | ||
using System.Text; | ||
|
||
#nullable enable | ||
|
||
namespace Microsoft.Data.SqlClient.AlwaysEncrypted; | ||
|
||
/// <summary> | ||
/// Represents metadata about the column master key, to be signed or verified by an enclave. | ||
/// </summary> | ||
/// <remarks> | ||
/// This metadata is a lower-case string which is laid out in the following format: | ||
/// <list type="number"> | ||
/// <item> | ||
/// Provider name. This always <see cref="SqlColumnEncryptionCertificateStoreProvider.ProviderName"/>. | ||
/// </item> | ||
/// <item> | ||
/// Master key path. This will be in the format [LocalMachine|CurrentUser]/My/[SHA1 thumbprint]. | ||
/// </item> | ||
/// <item> | ||
/// Boolean to indicate whether the CMK supports enclave computations. This is either <c>true</c> or <c>false</c>. | ||
/// </item> | ||
/// </list> | ||
/// <para> | ||
/// This takes ownership of the RSA instance supplied to it, disposing of it when Dispose is called. | ||
/// </para> | ||
/// </remarks> | ||
internal readonly ref struct ColumnMasterKeyMetadata // : IDisposable | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
private static readonly HashAlgorithmName s_hashAlgorithm = HashAlgorithmName.SHA256; | ||
|
||
#if NET | ||
[InlineArray(SHA256.HashSizeInBytes)] | ||
private struct Sha256Hash | ||
{ | ||
private byte _elementTemplate; | ||
} | ||
|
||
private readonly Sha256Hash _hash; | ||
#else | ||
private readonly byte[] _hash; | ||
#endif | ||
private readonly RSA _rsa; | ||
|
||
// @TODO: SqlColumnEncryptionCertificateStoreProvider.SignMasterKeyMetadata and .VerifyMasterKeyMetadata should use this type. | ||
public ColumnMasterKeyMetadata(RSA rsa, string masterKeyPath, string providerName, bool allowEnclaveComputations) | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
// Lay the column master key metadata out in memory. Then, calculate the hash of this metadata ready for signature or verification. | ||
// .NET Core supports Spans in more places, allowing us to allocate on the stack for better performance. It also supports the | ||
// SHA256.HashData method, which saves allocations compared to instantiating a SHA256 object and calling TransformFinalBlock. | ||
|
||
// By this point, we know that we have a valid certificate, so the path is valid. The longest valid masterKeyPath is in the format: | ||
// [LocalMachine|CurrentUser]/My/[40 character SHA1 thumbprint] | ||
// ProviderName is a constant string of length 23 characters, and allowEnclaveComputations' longest value is 5 characters long. This | ||
// implies a maximum length of 84 characters for the masterKeyMetadata string - and by extension, 168 bytes for the Unicode-encoded | ||
// byte array. This is small enough to allocate on the stack, but we fall back to allocating a new char/byte array in case those assumptions fail. | ||
ReadOnlySpan<char> enclaveComputationSpan = (allowEnclaveComputations ? bool.TrueString : bool.FalseString).AsSpan(); | ||
int masterKeyMetadataLength = providerName.Length + masterKeyPath.Length + enclaveComputationSpan.Length; | ||
int byteCount; | ||
|
||
#if NET | ||
const int CharStackAllocationThreshold = 128; | ||
const int ByteStackAllocationThreshold = CharStackAllocationThreshold * sizeof(char); | ||
|
||
Span<char> masterKeyMetadata = masterKeyMetadataLength <= CharStackAllocationThreshold | ||
? stackalloc char[CharStackAllocationThreshold].Slice(0, masterKeyMetadataLength) | ||
: new char[masterKeyMetadataLength]; | ||
Span<char> masterKeyMetadataSpan = masterKeyMetadata; | ||
#else | ||
char[] masterKeyMetadata = new char[masterKeyMetadataLength]; | ||
Span<char> masterKeyMetadataSpan = masterKeyMetadata.AsSpan(); | ||
#endif | ||
|
||
providerName.AsSpan().ToLowerInvariant(masterKeyMetadataSpan); | ||
masterKeyPath.AsSpan().ToLowerInvariant(masterKeyMetadataSpan.Slice(providerName.Length)); | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
enclaveComputationSpan.ToLowerInvariant(masterKeyMetadataSpan.Slice(providerName.Length + masterKeyPath.Length)); | ||
byteCount = Encoding.Unicode.GetByteCount(masterKeyMetadata); | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
#if NET | ||
Span<byte> masterKeyMetadataBytes = byteCount <= ByteStackAllocationThreshold | ||
? stackalloc byte[ByteStackAllocationThreshold].Slice(0, byteCount) | ||
: new byte[byteCount]; | ||
|
||
Encoding.Unicode.GetBytes(masterKeyMetadata, masterKeyMetadataBytes); | ||
|
||
// Compute hash | ||
SHA256.HashData(masterKeyMetadataBytes, _hash); | ||
#else | ||
byte[] masterKeyMetadataBytes = Encoding.Unicode.GetBytes(masterKeyMetadata); | ||
using SHA256 sha256 = SHA256.Create(); | ||
paulmedynski marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
// Compute hash | ||
sha256.TransformFinalBlock(masterKeyMetadataBytes, 0, masterKeyMetadataBytes.Length); | ||
_hash = sha256.Hash; | ||
#endif | ||
|
||
_rsa = rsa; | ||
} | ||
|
||
public byte[] Sign() => | ||
_rsa.SignHash(_hash, s_hashAlgorithm, RSASignaturePadding.Pkcs1); | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
public bool Verify(byte[] signature) => | ||
_rsa.VerifyHash(_hash, signature, s_hashAlgorithm, RSASignaturePadding.Pkcs1); | ||
|
||
public void Dispose() => | ||
_rsa.Dispose(); | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} |
220 changes: 220 additions & 0 deletions
220
...nt/src/Microsoft/Data/SqlClient/AlwaysEncrypted/EncryptedColumnEncryptionKeyParameters.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,220 @@ | ||
// Licensed to the .NET Foundation under one or more agreements. | ||
// The .NET Foundation licenses this file to you under the MIT license. | ||
// See the LICENSE file in the project root for more information. | ||
|
||
using System; | ||
using System.Buffers.Binary; | ||
using System.Diagnostics; | ||
using System.Security.Cryptography; | ||
using System.Text; | ||
|
||
#nullable enable | ||
|
||
namespace Microsoft.Data.SqlClient.AlwaysEncrypted; | ||
|
||
/// <summary> | ||
/// Represents the parameters used to construct an encrypted column encryption key, used to encrypt and decrypt data in SQL Server Always Encrypted columns. | ||
/// </summary> | ||
/// <remarks> | ||
/// An encrypted CEK is a byte array that contains the following structure: | ||
/// <list type="number"> | ||
/// <item> | ||
/// Version: 1 byte, always 0x01 | ||
/// </item> | ||
/// <item> | ||
/// Key path length: 2 bytes, length of the key path in bytes | ||
paulmedynski marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
/// </item> | ||
/// <item> | ||
/// Ciphertext length: 2 bytes, length of the ciphertext in bytes | ||
/// </item> | ||
/// <item> | ||
/// Key path: variable length, Unicode-encoded string representing the key path | ||
paulmedynski marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
/// </item> | ||
/// <item> | ||
/// Ciphertext: variable length, encrypted data. Length determined by size of the RSA key used for encryption | ||
/// </item> | ||
/// <item> | ||
/// Signature: variable length, digital signature of the encrypted CEK's SHA256 hash. Length determined by the size of the RSA key used for signing | ||
/// </item> | ||
/// </list> | ||
/// <para> | ||
/// This takes ownership of the RSA instance supplied to it, disposing of it when Dispose is called. | ||
/// </para> | ||
/// </remarks> | ||
internal readonly ref struct EncryptedColumnEncryptionKeyParameters // : IDisposable | ||
{ | ||
private const byte AlgorithmVersion = 0x01; | ||
|
||
private const int AlgorithmOffset = 0; | ||
private const int KeyPathLengthOffset = AlgorithmOffset + sizeof(byte); | ||
private const int CiphertextLengthOffset = KeyPathLengthOffset + sizeof(ushort); | ||
private const int KeyPathOffset = CiphertextLengthOffset + sizeof(ushort); | ||
|
||
#if NET | ||
private const int HashSize = SHA256.HashSizeInBytes; | ||
#else | ||
private const int HashSize = 32; | ||
#endif | ||
|
||
private static readonly HashAlgorithmName s_hashAlgorithm = HashAlgorithmName.SHA256; | ||
|
||
private readonly RSA _rsa; | ||
private readonly int _rsaKeySize; | ||
private readonly string _keyPath; | ||
private readonly string _keyType; | ||
private readonly string _keyPathReference; | ||
|
||
// @TODO: SqlColumnEncryptionCertificateStoreProvider, SqlColumnEncryptionCngProvider and SqlColumnEncryptionCspProvider should use this type. | ||
public EncryptedColumnEncryptionKeyParameters(RSA rsa, string keyPath, string keyType, string keyPathReference) | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
_rsa = rsa; | ||
_rsaKeySize = rsa.KeySize / 8; | ||
_keyPath = keyPath; | ||
|
||
Debug.Assert(keyType is SqlColumnEncryptionCertificateStoreProvider.MasterKeyType | ||
or SqlColumnEncryptionCngProvider.MasterKeyType or SqlColumnEncryptionCspProvider.MasterKeyType); | ||
Debug.Assert(keyPathReference is SqlColumnEncryptionCertificateStoreProvider.KeyPathReference | ||
or SqlColumnEncryptionCngProvider.KeyPathReference or SqlColumnEncryptionCspProvider.KeyPathReference); | ||
_keyType = keyType; | ||
_keyPathReference = keyPathReference; | ||
} | ||
|
||
public byte[] Encrypt(byte[] columnEncryptionKey) | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
{ | ||
ushort keyPathSize = (ushort)Encoding.Unicode.GetByteCount(_keyPath); | ||
int cekSize = sizeof(byte) + sizeof(ushort) + sizeof(ushort) + keyPathSize + _rsaKeySize + _rsaKeySize; | ||
paulmedynski marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
byte[] encryptedColumnEncryptionKey = new byte[cekSize]; | ||
int bytesWritten; | ||
int cipherTextOffset = KeyPathOffset + keyPathSize; | ||
int signatureOffset = cipherTextOffset + _rsaKeySize; | ||
|
||
// We currently only support one version | ||
encryptedColumnEncryptionKey[AlgorithmOffset] = AlgorithmVersion; | ||
|
||
// Write the key path length and the ciphertext length | ||
BinaryPrimitives.WriteUInt16LittleEndian(encryptedColumnEncryptionKey.AsSpan(KeyPathLengthOffset), keyPathSize); | ||
BinaryPrimitives.WriteUInt16LittleEndian(encryptedColumnEncryptionKey.AsSpan(CiphertextLengthOffset), (ushort)_rsaKeySize); | ||
|
||
// Write the unicode encoded bytes of the key path | ||
bytesWritten = Encoding.Unicode.GetBytes(_keyPath, 0, _keyPath.Length, encryptedColumnEncryptionKey, KeyPathOffset); | ||
Debug.Assert(bytesWritten == keyPathSize, @"Key path length does not match the expected length."); | ||
|
||
// Encrypt the column encryption key using RSA with OAEP padding. | ||
// In .NET Core, we can encrypt directly into the byte array, while in .NET Framework we need to allocate an intermediary and copy. | ||
#if NET | ||
// CodeQL [SM03796] Required for an external standard: Always Encrypted only supports encrypting column encryption keys with RSA_OAEP(SHA1) (https://learn.microsoft.com/en-us/sql/t-sql/statements/create-column-encryption-key-transact-sql?view=sql-server-ver16) | ||
bytesWritten = _rsa.Encrypt(columnEncryptionKey, encryptedColumnEncryptionKey.AsSpan(cipherTextOffset), RSAEncryptionPadding.OaepSHA1); | ||
#else | ||
// CodeQL [SM03796] Required for an external standard: Always Encrypted only supports encrypting column encryption keys with RSA_OAEP(SHA1) (https://learn.microsoft.com/en-us/sql/t-sql/statements/create-column-encryption-key-transact-sql?view=sql-server-ver16) | ||
byte[] cipherText = _rsa.Encrypt(columnEncryptionKey, RSAEncryptionPadding.OaepSHA1); | ||
bytesWritten = cipherText.Length; | ||
|
||
Buffer.BlockCopy(cipherText, 0, encryptedColumnEncryptionKey, cipherTextOffset, bytesWritten); | ||
#endif | ||
Debug.Assert(bytesWritten == _rsaKeySize, @"Ciphertext length does not match the RSA key size."); | ||
|
||
// Compute the SHA256 hash of the encrypted CEK, (up to this point) then sign it and write the signature | ||
// In .NET Core, we can use a stack-allocated span for the hash, while in .NET Framework we need to allocate a byte array. | ||
#if NET | ||
Span<byte> hash = stackalloc byte[HashSize]; | ||
bytesWritten = SHA256.HashData(encryptedColumnEncryptionKey.AsSpan(0, signatureOffset), hash); | ||
Debug.Assert(bytesWritten == HashSize, @"Hash size does not match the expected size."); | ||
|
||
bytesWritten = _keyType == SqlColumnEncryptionCertificateStoreProvider.MasterKeyType | ||
? _rsa.SignHash(hash, encryptedColumnEncryptionKey.AsSpan(signatureOffset), s_hashAlgorithm, RSASignaturePadding.Pkcs1) | ||
: _rsa.SignData(hash, encryptedColumnEncryptionKey.AsSpan(signatureOffset), s_hashAlgorithm, RSASignaturePadding.Pkcs1); | ||
Debug.Assert(bytesWritten == _rsaKeySize, @"Signature length does not match the RSA key size."); | ||
|
||
#else | ||
byte[] hash; | ||
using (SHA256 sha256 = SHA256.Create()) | ||
{ | ||
sha256.TransformFinalBlock(encryptedColumnEncryptionKey, 0, signatureOffset); | ||
hash = sha256.Hash; | ||
} | ||
bytesWritten = hash.Length; | ||
Debug.Assert(bytesWritten == HashSize, @"Hash size does not match the expected size."); | ||
|
||
byte[] signedHash = _keyType == SqlColumnEncryptionCertificateStoreProvider.MasterKeyType | ||
? _rsa.SignHash(hash, s_hashAlgorithm, RSASignaturePadding.Pkcs1) | ||
paulmedynski marked this conversation as resolved.
Show resolved
Hide resolved
|
||
: _rsa.SignData(hash, s_hashAlgorithm, RSASignaturePadding.Pkcs1); | ||
bytesWritten = signedHash.Length; | ||
Debug.Assert(bytesWritten == _rsaKeySize, @"Signature length does not match the RSA key size."); | ||
|
||
Buffer.BlockCopy(signedHash, 0, encryptedColumnEncryptionKey, signatureOffset, bytesWritten); | ||
#endif | ||
|
||
return encryptedColumnEncryptionKey; | ||
} | ||
|
||
public byte[] Decrypt(byte[] encryptedCek) | ||
{ | ||
// Validate the version byte | ||
if (encryptedCek[0] != AlgorithmVersion) | ||
paulmedynski marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
{ | ||
throw SQL.InvalidAlgorithmVersionInEncryptedCEK(encryptedCek[0], AlgorithmVersion); | ||
} | ||
|
||
// Get key path length, but skip reading it. It exists only for troubleshooting purposes and doesn't need validation. | ||
ushort keyPathLength = BinaryPrimitives.ReadUInt16LittleEndian(encryptedCek.AsSpan(KeyPathLengthOffset)); | ||
|
||
// Get ciphertext length, then validate it against the RSA key size | ||
ushort cipherTextLength = BinaryPrimitives.ReadUInt16LittleEndian(encryptedCek.AsSpan(CiphertextLengthOffset)); | ||
|
||
if (cipherTextLength != _rsaKeySize) | ||
{ | ||
throw SQL.InvalidCiphertextLengthInEncryptedCEK(_keyType, _keyPathReference, cipherTextLength, _rsaKeySize, _keyPath); | ||
} | ||
|
||
// Validate the signature length | ||
int cipherTextOffset = KeyPathOffset + keyPathLength; | ||
int signatureOffset = cipherTextOffset + cipherTextLength; | ||
int signatureLength = encryptedCek.Length - signatureOffset; | ||
|
||
if (signatureLength != _rsaKeySize) | ||
{ | ||
throw SQL.InvalidSignatureInEncryptedCEK(_keyType, _keyPathReference, signatureLength, _rsaKeySize, _keyPath); | ||
} | ||
|
||
// Get the ciphertext and signature, then calculate the hash of the encrypted CEK. | ||
// In .NET Core most of these operations can be done with spans, while in .NET Framework we need to allocate byte arrays. | ||
#if NET | ||
Span<byte> cipherText = encryptedCek.AsSpan(cipherTextOffset, cipherTextLength); | ||
Span<byte> signature = encryptedCek.AsSpan(signatureOffset); | ||
|
||
Span<byte> hash = stackalloc byte[HashSize]; | ||
SHA256.HashData(encryptedCek.AsSpan(0, signatureOffset), hash); | ||
#else | ||
byte[] cipherText = new byte[cipherTextLength]; | ||
Buffer.BlockCopy(encryptedCek, cipherTextOffset, cipherText, 0, cipherText.Length); | ||
|
||
byte[] signature = new byte[signatureLength]; | ||
Buffer.BlockCopy(encryptedCek, signatureOffset, signature, 0, signature.Length); | ||
|
||
byte[] hash; | ||
using (SHA256 sha256 = SHA256.Create()) | ||
{ | ||
sha256.TransformFinalBlock(encryptedCek, 0, signatureOffset); | ||
hash = sha256.Hash; | ||
} | ||
Debug.Assert(hash.Length == HashSize, @"hash length should be same as the signature length while decrypting encrypted column encryption key."); | ||
#endif | ||
|
||
bool dataVerified = _keyType == SqlColumnEncryptionCertificateStoreProvider.MasterKeyType | ||
? _rsa.VerifyHash(hash, signature, s_hashAlgorithm, RSASignaturePadding.Pkcs1) | ||
: _rsa.VerifyData(hash, signature, s_hashAlgorithm, RSASignaturePadding.Pkcs1); | ||
|
||
// Validate the signature | ||
if (!dataVerified) | ||
{ | ||
throw SQL.InvalidSignature(_keyPath, _keyType); | ||
} | ||
|
||
// Decrypt the CEK | ||
// CodeQL [SM03796] Required for an external standard: Always Encrypted only supports encrypting column encryption keys with RSA_OAEP(SHA1) (https://learn.microsoft.com/en-us/sql/t-sql/statements/create-column-encryption-key-transact-sql?view=sql-server-ver16) | ||
return _rsa.Decrypt(cipherText, RSAEncryptionPadding.OaepSHA1); | ||
} | ||
|
||
public void Dispose() => | ||
_rsa.Dispose(); | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.