Replace legacy encryption with AES-GCM

This commit is contained in:
Andy Howard
2026-09-16 18:40:32 +01:00
parent 6989c5f9b6
commit a0a5ef970c
13 changed files with 456 additions and 278 deletions
@@ -0,0 +1,93 @@
using System;
using NUnit.Framework;
using System.Linq;
using System.Security.Cryptography;
using Sarand.Net.Standard.Base.Data;
using Sarand.Net.Standard.Base.Helpers;
namespace Sacs.UnitTests.Net.Standard.Base.Data
{
[TestFixture]
public class SacsAesGcmCrypter_Tester
{
// --- Data Members ---
private static readonly Random random = new Random(11437);
// --- Tests ---
[Test]
public void EncryptDecrypt_RoundTripsRandomText()
{
const String purpose = "Tests:SacsAesGcmCrypter";
for (int i = 0; i < 200; i++)
{
String data = RandomString(random.Next(10, 2048), true);
String key = RandomString(random.Next(10, 128), true);
String encrypted = SacsAesGcmCrypter.Encrypt(data, key, purpose);
Assert.That(SacsAesGcmCrypter.Decrypt(encrypted, key, purpose), Is.EqualTo(data));
}
}
[Test]
public void Decrypt_RejectsLegacyCiphertext()
{
const String encrypted = "0qlOvuM3L9+yzc6XnuHSv2Jq/1v1pyPq6CaCYnMauW6n+bBc9YOBNPtaNFm+2JGz;zgCog0OpwdN+I8yK5UAU8g==";
const String key = "Compatibility-Key-2026!";
Assert.That(
() => SacsAesGcmCrypter.Decrypt(encrypted, key, "Tests:SacsAesGcmCrypter"),
Throws.TypeOf<CryptographicException>());
}
[Test]
public void Decrypt_RejectsUnencryptedText()
{
const String value = "A value without an appended IV";
Assert.That(
() => SacsAesGcmCrypter.Decrypt(value, "unused-key", "Tests:SacsAesGcmCrypter"),
Throws.TypeOf<CryptographicException>());
}
[Test]
public void Encrypt_UsesUniqueNonces()
{
const String value = "Repeated value";
const String key = "Repeated-key";
const String purpose = "Tests:SacsAesGcmCrypter";
String first = SacsAesGcmCrypter.Encrypt(value, key, purpose);
String second = SacsAesGcmCrypter.Encrypt(value, key, purpose);
Assert.That(first, Is.Not.EqualTo(second));
Assert.That(first, Does.StartWith(SacsAuthenticatedEncryption.EnvelopePrefix));
Assert.That(SacsAesGcmCrypter.Decrypt(first, key, purpose), Is.EqualTo(value));
Assert.That(SacsAesGcmCrypter.Decrypt(second, key, purpose), Is.EqualTo(value));
}
[Test]
public void Decrypt_RejectsWrongPurpose()
{
String encrypted = SacsAesGcmCrypter.Encrypt("Purpose-bound", "purpose-key", "Tests:FirstPurpose");
Assert.That(
() => SacsAesGcmCrypter.Decrypt(encrypted, "purpose-key", "Tests:SecondPurpose"),
Throws.TypeOf<AuthenticationTagMismatchException>());
}
// --- Utilities ---
/// <summary>Generate random alphanumeric string</summary>
private static String RandomString(int length, bool includeLowerCase = true, bool includeSymbols = true)
{
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
if (includeLowerCase) chars += "abcdefghijklmnopqrstuvwxyz";
if (includeSymbols) chars += "`¬!\"£$%^&*()-=_+{}[];:'@#~<>?/.,\\| ";
return new string(Enumerable.Repeat(chars, length).Select(s => s[random.Next(s.Length)]).ToArray());
}
// --- The End ---
}
}
@@ -1,92 +0,0 @@
using System;
using System.Text;
using System.Collections.Generic;
using NUnit.Framework;
using System.Linq;
using Sarand.Net.Standard.Base.Data;
namespace Sacs.UnitTests.Net.Standard.Base.Data
{
[TestFixture]
public class SacsAesIvCrypter_Tester
{
// --- Data Members ---
private static readonly Random random = new Random(11437);
// --- Tests ---
[Test]
public void EncryptDecrypt_RoundTripsRandomText()
{
for (int i = 0; i < 200; i++)
{
String data = RandomString(random.Next(10, 2048), true);
String key = RandomString(random.Next(10, 128), true);
String encrypted = SacsAesIvCrypter.Encrypt(data, key);
Assert.That(SacsAesIvCrypter.Decrypt(encrypted, key), Is.EqualTo(data));
}
}
[Test]
public void Decrypt_ReadsLegacyCiphertext()
{
const String encrypted = "0qlOvuM3L9+yzc6XnuHSv2Jq/1v1pyPq6CaCYnMauW6n+bBc9YOBNPtaNFm+2JGz;zgCog0OpwdN+I8yK5UAU8g==";
const String key = "Compatibility-Key-2026!";
String decrypted = SacsAesIvCrypter.Decrypt(encrypted, key);
Assert.That(decrypted, Is.EqualTo("Legacy compatibility payload £ € 漢字"));
}
[Test]
public void Decrypt_ReturnsUnencryptedTextUnchanged()
{
const String value = "A value without an appended IV";
String decrypted = SacsAesIvCrypter.Decrypt(value, "unused-key");
Assert.That(decrypted, Is.SameAs(value));
}
[Test]
public void Encrypt_UsesUniqueInitializationVectors()
{
const String value = "Repeated value";
const String key = "Repeated-key";
String first = SacsAesIvCrypter.Encrypt(value, key);
String second = SacsAesIvCrypter.Encrypt(value, key);
Assert.That(first, Is.Not.EqualTo(second));
Assert.That(SacsAesIvCrypter.Decrypt(first, key), Is.EqualTo(value));
Assert.That(SacsAesIvCrypter.Decrypt(second, key), Is.EqualTo(value));
}
[Test]
public void Encrypt_ProducesKnownCiphertextForExplicitKeyAndIv()
{
Byte[] key = Enumerable.Range(0, 32).Select(value => (Byte)value).ToArray();
Byte[] iv = Enumerable.Range(16, 16).Select(value => (Byte)value).ToArray();
Byte[] encrypted = SacsAesIvCrypter.Encrypt("Known vector £ 漢字", key, iv);
Assert.That(Convert.ToBase64String(encrypted), Is.EqualTo("9yYFmwEr9e00Z3WMjGRWfkgT/XwV8OJ4Encs4x9HiVo="));
Assert.That(SacsAesIvCrypter.Decrypt(encrypted, key, iv), Is.EqualTo("Known vector £ 漢字"));
}
// --- Utilities ---
/// <summary>Generate random alphanumeric string</summary>
private static String RandomString(int length, bool includeLowerCase = true, bool includeSymbols = true)
{
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
if (includeLowerCase) chars += "abcdefghijklmnopqrstuvwxyz";
if (includeSymbols) chars += "`¬!\"£$%^&*()-=_+{}[];:'@#~<>?/.,\\| ";
return new string(Enumerable.Repeat(chars, length).Select(s => s[random.Next(s.Length)]).ToArray());
}
// --- The End ---
}
}
@@ -0,0 +1,93 @@
using System;
using System.Security.Cryptography;
using NUnit.Framework;
using Sarand.Net.Standard.Base.Helpers;
namespace Sacs.UnitTests.Net.Standard.Base.Helpers
{
[TestFixture]
public class SacsAuthenticatedEncryption_Tester
{
// --- Data Members ---
private static readonly Byte[] Key = Convert.FromHexString("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F");
private const String Purpose = "Tests:AuthenticatedEnvelope";
// --- Tests ---
[TestCase("")]
[TestCase("Plain ASCII text")]
[TestCase("Unicode £ € 漢字 and emoji 😀")]
public void EncryptDecrypt_RoundTrips(String value)
{
String encrypted = SacsAuthenticatedEncryption.Encrypt(value, Key, Purpose);
Assert.That(encrypted, Does.StartWith(SacsAuthenticatedEncryption.EnvelopePrefix));
Assert.That(encrypted, Does.Not.Contain("+").And.Not.Contain("/").And.Not.EndWith("="));
Assert.That(SacsAuthenticatedEncryption.Decrypt(encrypted, Key, Purpose), Is.EqualTo(value));
}
[Test]
public void Encrypt_UsesUniqueNonces()
{
String first = SacsAuthenticatedEncryption.Encrypt("Repeated value", Key, Purpose);
String second = SacsAuthenticatedEncryption.Encrypt("Repeated value", Key, Purpose);
Assert.That(second, Is.Not.EqualTo(first));
}
[Test]
public void Decrypt_RejectsModifiedEnvelope()
{
String encrypted = SacsAuthenticatedEncryption.Encrypt("Protected value", Key, Purpose);
Int32 modifiedIndex = SacsAuthenticatedEncryption.EnvelopePrefix.Length;
Char replacement = encrypted[modifiedIndex] == 'A' ? 'B' : 'A';
String modified = encrypted.Substring(0, modifiedIndex) + replacement + encrypted.Substring(modifiedIndex + 1);
Assert.That(
() => SacsAuthenticatedEncryption.Decrypt(modified, Key, Purpose),
Throws.TypeOf<AuthenticationTagMismatchException>());
}
[Test]
public void Decrypt_RejectsWrongKey()
{
String encrypted = SacsAuthenticatedEncryption.Encrypt("Protected value", Key, Purpose);
Byte[] wrongKey = Convert.FromHexString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF");
Assert.That(
() => SacsAuthenticatedEncryption.Decrypt(encrypted, wrongKey, Purpose),
Throws.TypeOf<AuthenticationTagMismatchException>());
}
[Test]
public void Decrypt_RejectsWrongPurpose()
{
String encrypted = SacsAuthenticatedEncryption.Encrypt("Protected value", Key, Purpose);
Assert.That(
() => SacsAuthenticatedEncryption.Decrypt(encrypted, Key, "Tests:DifferentPurpose"),
Throws.TypeOf<AuthenticationTagMismatchException>());
}
[TestCase("legacy-ciphertext")]
[TestCase("SACSENC2.not-valid-base64!")]
[TestCase("SACSENC2.AA")]
public void Decrypt_RejectsUnsupportedOrMalformedValues(String value)
{
Assert.That(
() => SacsAuthenticatedEncryption.Decrypt(value, Key, Purpose),
Throws.TypeOf<CryptographicException>());
}
[Test]
public void Encrypt_RejectsInvalidKeyLength()
{
Assert.That(
() => SacsAuthenticatedEncryption.Encrypt("value", new Byte[15], Purpose),
Throws.TypeOf<ArgumentException>());
}
// --- The End ---
}
}
@@ -36,15 +36,14 @@ namespace Sacs.UnitTests.Net.Standard.Base.Helpers
Assert.That(() => SacsEncryptionHelper.EncodePlainKey(value), Throws.TypeOf<NotSupportedException>()); Assert.That(() => SacsEncryptionHelper.EncodePlainKey(value), Throws.TypeOf<NotSupportedException>());
} }
// --- Key and IV Generation --- // --- Key Generation ---
[Test] [Test]
public void InitSymmetricEncryptionKeyIV_ReturnsAes256KeyAndBlockSizedIv() public void InitSymmetricEncryptionKey_ReturnsAes256Key()
{ {
(String key, String iv) = SacsEncryptionHelper.InitSymmetricEncryptionKeyIV(); String key = SacsEncryptionHelper.InitSymmetricEncryptionKey();
Assert.That(Convert.FromBase64String(key), Has.Length.EqualTo(32)); Assert.That(Convert.FromBase64String(key), Has.Length.EqualTo(32));
Assert.That(Convert.FromBase64String(iv), Has.Length.EqualTo(16));
} }
[Test] [Test]
@@ -63,12 +62,13 @@ namespace Sacs.UnitTests.Net.Standard.Base.Helpers
[TestCase("Unicode £ € 漢字 and emoji 😀")] [TestCase("Unicode £ € 漢字 and emoji 😀")]
public void EncryptDecrypt_RoundTripsText(String value) public void EncryptDecrypt_RoundTripsText(String value)
{ {
(String key, String iv) = SacsEncryptionHelper.InitSymmetricEncryptionKeyIV(); String key = SacsEncryptionHelper.InitSymmetricEncryptionKey();
String encrypted = SacsEncryptionHelper.Encrypt(value, iv, key); String encrypted = SacsEncryptionHelper.EncryptAuthenticated(value, key, "Tests:SacsEncryptionHelper");
String decrypted = SacsEncryptionHelper.Decrypt(encrypted, iv, key); String decrypted = SacsEncryptionHelper.DecryptAuthenticated(encrypted, key, "Tests:SacsEncryptionHelper");
Assert.That(encrypted, Is.Not.EqualTo(value)); Assert.That(encrypted, Is.Not.EqualTo(value));
Assert.That(encrypted, Does.StartWith(SacsAuthenticatedEncryption.EnvelopePrefix));
Assert.That(decrypted, Is.EqualTo(value)); Assert.That(decrypted, Is.EqualTo(value));
} }
+4 -4
View File
@@ -122,7 +122,7 @@ namespace Sacs.Web.Components
} }
//------------------------------------------------------------------- //-------------------------------------------------------------------
public static String EncryptParameters(NameValueCollection parameters, String base64IV, String base64Key) public static String EncryptParameters(NameValueCollection parameters, String base64Key, String purpose)
{ {
try try
{ {
@@ -137,18 +137,18 @@ namespace Sacs.Web.Components
queryString += String.Join("&", paramKeys.Select(a => a + "=" + HttpUtility.UrlEncode(parametersToEncrypt[a]))); queryString += String.Join("&", paramKeys.Select(a => a + "=" + HttpUtility.UrlEncode(parametersToEncrypt[a])));
// Encrypt data. // Encrypt data.
String encrypted = SacsEncryptionHelper.Encrypt(queryString, base64IV, base64Key); String encrypted = SacsEncryptionHelper.EncryptAuthenticated(queryString, base64Key, purpose);
//if (SacsSystem.DebugMode) SaLogger.Debug(SysUtil.Fn + "\n\tData: " + queryString + " \n\tEncrypted: " + encrypted); //if (SacsSystem.DebugMode) SaLogger.Debug(SysUtil.Fn + "\n\tData: " + queryString + " \n\tEncrypted: " + encrypted);
return encrypted; return encrypted;
} }
catch (Exception exc) { SaLogger.Warn($"{SysUtil.Fn} {ExceptionUtil.GetErrorStateMessage(exc)}"); } catch (Exception exc) { SaLogger.Warn($"{SysUtil.Fn} {ExceptionUtil.GetErrorStateMessage(exc)}"); }
return ""; return "";
} }
public static NameValueCollection DecryptParameters(String encrypted, String base64IV, String base64Key) public static NameValueCollection DecryptParameters(String encrypted, String base64Key, String purpose)
{ {
try try
{ {
String queryString = SacsEncryptionHelper.Decrypt(encrypted, base64IV, base64Key); String queryString = SacsEncryptionHelper.DecryptAuthenticated(encrypted, base64Key, purpose);
//if (SacsSystem.DebugMode) SaLogger.Debug(SysUtil.Fn + " \n\tEncrypted: " + encrypted + "\n\tData: " + queryString); //if (SacsSystem.DebugMode) SaLogger.Debug(SysUtil.Fn + " \n\tEncrypted: " + encrypted + "\n\tData: " + queryString);
return HttpUtility.ParseQueryString(queryString); return HttpUtility.ParseQueryString(queryString);
} }
@@ -0,0 +1,32 @@
using Sarand.Net.Standard.Base.Helpers;
using System;
using System.Security.Cryptography;
using System.Text;
namespace Sarand.Net.Standard.Base.Data
{
public static class SacsAesGcmCrypter
{
// --- Key Derivation ---
private static Byte[] CreateKey(String keyValue)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyValue);
return SHA256.HashData(Encoding.UTF8.GetBytes(keyValue));
}
// --- Authenticated Encryption ---
public static String Encrypt(this String clearValue, String encryptionKey, String purpose)
{
return SacsAuthenticatedEncryption.Encrypt(clearValue, CreateKey(encryptionKey), purpose);
}
public static String Decrypt(this String encryptedValue, String encryptionKey, String purpose)
{
return SacsAuthenticatedEncryption.Decrypt(encryptedValue, CreateKey(encryptionKey), purpose);
}
// --- The End ---
}
}
@@ -1,113 +0,0 @@
using Sarand.Net.Standard.Base.App;
using System;
using System.IO;
using System.Security.Cryptography;
namespace Sarand.Net.Standard.Base.Data
{
//=======================================================================
public static class SacsAesIvCrypter
{
//-------------------------------------------------------------------
// Properties.
public static String AesIvDelimiter { get; } = ";";
public static String AesIvPrefix { get; } = "AESIV:";
//-------------------------------------------------------------------
// Ref. https://xamarinhelp.com/cryptography-in-xamarin-forms/
// For reversible encryption, AES is generally the recommended standard.
// AES 256 requires a 32 byte key to encrypt.
// Either generate a 32 byte key or use any string and convert it into a key with CreateKey.
// NB The salt will remain the same here for each key generated - it does not need to be randomized as with hashing.
//-------------------------------------------------------------------
private static byte[] CreateKey(string password, int keyBytes = 32)
{
byte[] salt = new byte[] { 80, 70, 60, 50, 40, 30, 20, 10 };
int iterations = 300;
return Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, keyBytes);
}
//-------------------------------------------------------------------
// Encrypt String.
// Return the encrypted text with the IV appended to the end.
// In AES, the IV is a randomizer, making sure that no two identical messages ever give the same encrypted output.
// The IV can be public and does not need to be hidden.
//-------------------------------------------------------------------
public static string Encrypt(this string clearValue, string encryptionKey)
{
using (Aes aes = Aes.Create())
{
aes.Key = CreateKey(encryptionKey);
byte[] aesIV = aes.IV;
byte[] encrypted = Encrypt(clearValue, aes.Key, aesIV);
return Convert.ToBase64String(encrypted) + AesIvDelimiter + Convert.ToBase64String(aesIV);
}
}
//-------------------------------------------------------------------
public static byte[] Encrypt(string plainText, byte[] key, byte[] iv)
{
if (plainText == null || plainText.Length <= 0) throw new ArgumentNullException($"{nameof(plainText)}");
if (key == null || key.Length <= 0) throw new ArgumentNullException($"{nameof(key)}");
if (iv == null || iv.Length <= 0) throw new ArgumentNullException($"{nameof(iv)}");
byte[] encrypted;
using (Aes aes = Aes.Create())
{
aes.Key = key;
aes.IV = iv;
using (MemoryStream memoryStream = new MemoryStream())
{
using (ICryptoTransform encryptor = aes.CreateEncryptor())
using (CryptoStream cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write))
using (StreamWriter streamWriter = new StreamWriter(cryptoStream))
{
streamWriter.Write(plainText);
}
encrypted = memoryStream.ToArray();
}
}
return encrypted;
}
//-------------------------------------------------------------------
// Decrypt String.
// Decryption requires the encrypted value and the secret key.
// This function is directly related to the above encryption process and will
// assume that the IV is at the end of the string passed in.
// To decrypt a message encrypted by another function, the IV will have to be passed explicitly.
//-------------------------------------------------------------------
public static string Decrypt(this string encryptedValue, string encryptionKey)
{
String[] valueParts = encryptedValue.Split(new String[] { AesIvDelimiter }, StringSplitOptions.None);
if (valueParts.Length != 2)
{
SaLogger.Warn("SacsAesIvCrypter.Decrypt Missing IV - assuming not encrypted");
return encryptedValue;
//throw new CryptographicException("Decrypt failure: Missing IV");
}
return Decrypt(Convert.FromBase64String(valueParts[0]), CreateKey(encryptionKey), Convert.FromBase64String(valueParts[1]));
}
//-------------------------------------------------------------------
public static string Decrypt(byte[] cipherText, byte[] key, byte[] iv)
{
if (cipherText == null || cipherText.Length <= 0) throw new ArgumentNullException($"{nameof(cipherText)}");
if (key == null || key.Length <= 0) throw new ArgumentNullException($"{nameof(key)}");
if (iv == null || iv.Length <= 0) throw new ArgumentNullException($"{nameof(iv)}");
string plaintext = "";
using (Aes aes = Aes.Create())
{
aes.Key = key;
aes.IV = iv;
using (MemoryStream memoryStream = new MemoryStream(cipherText))
using (ICryptoTransform decryptor = aes.CreateDecryptor())
using (CryptoStream cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read))
using (StreamReader streamReader = new StreamReader(cryptoStream))
plaintext = streamReader.ReadToEnd();
}
return plaintext;
}
//-------------------------------------------------------------------
}
//=======================================================================
}
@@ -0,0 +1,109 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace Sarand.Net.Standard.Base.Helpers
{
public static class SacsAuthenticatedEncryption
{
// --- Definitions ---
public const String EnvelopePrefix = "SACSENC2.";
public const Int32 NonceLength = 12;
public const Int32 TagLength = 16;
public const Int32 MaximumEnvelopeLength = 1024 * 1024;
// --- Encryption ---
public static String Encrypt(String plaintext, Byte[] key, String purpose)
{
ArgumentNullException.ThrowIfNull(plaintext);
ValidateKeyAndPurpose(key, purpose);
Byte[] plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
Byte[] nonce = RandomNumberGenerator.GetBytes(NonceLength);
Byte[] ciphertext = new Byte[plaintextBytes.Length];
Byte[] tag = new Byte[TagLength];
Byte[] associatedData = Encoding.UTF8.GetBytes(purpose);
using AesGcm aes = new AesGcm(key, TagLength);
aes.Encrypt(nonce, plaintextBytes, ciphertext, tag, associatedData);
Byte[] envelope = new Byte[NonceLength + TagLength + ciphertext.Length];
nonce.CopyTo(envelope, 0);
tag.CopyTo(envelope, NonceLength);
ciphertext.CopyTo(envelope, NonceLength + TagLength);
return EnvelopePrefix + ToBase64Url(envelope);
}
public static String Decrypt(String encryptedValue, Byte[] key, String purpose)
{
ArgumentException.ThrowIfNullOrWhiteSpace(encryptedValue);
ValidateKeyAndPurpose(key, purpose);
if (!encryptedValue.StartsWith(EnvelopePrefix, StringComparison.Ordinal))
{
throw new CryptographicException("The encrypted value does not use the supported authenticated format.");
}
if (encryptedValue.Length > MaximumEnvelopeLength)
{
throw new CryptographicException("The encrypted value exceeds the supported size.");
}
Byte[] envelope;
try
{
envelope = FromBase64Url(encryptedValue.Substring(EnvelopePrefix.Length));
}
catch (FormatException exception)
{
throw new CryptographicException("The encrypted value is malformed.", exception);
}
if (envelope.Length < NonceLength + TagLength)
{
throw new CryptographicException("The encrypted value is incomplete.");
}
ReadOnlySpan<Byte> nonce = envelope.AsSpan(0, NonceLength);
ReadOnlySpan<Byte> tag = envelope.AsSpan(NonceLength, TagLength);
ReadOnlySpan<Byte> ciphertext = envelope.AsSpan(NonceLength + TagLength);
Byte[] plaintext = new Byte[ciphertext.Length];
Byte[] associatedData = Encoding.UTF8.GetBytes(purpose);
using AesGcm aes = new AesGcm(key, TagLength);
aes.Decrypt(nonce, ciphertext, tag, plaintext, associatedData);
return Encoding.UTF8.GetString(plaintext);
}
// --- Utilities ---
private static void ValidateKeyAndPurpose(Byte[] key, String purpose)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentException.ThrowIfNullOrWhiteSpace(purpose);
if (key.Length != 16 && key.Length != 24 && key.Length != 32)
{
throw new ArgumentException("AES keys must contain 16, 24, or 32 bytes.", nameof(key));
}
if (!AesGcm.IsSupported)
{
throw new PlatformNotSupportedException("AES-GCM is not supported on this platform.");
}
}
private static String ToBase64Url(Byte[] value)
{
return Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
private static Byte[] FromBase64Url(String value)
{
String base64 = value.Replace('-', '+').Replace('_', '/');
base64 = base64.PadRight(base64.Length + ((4 - base64.Length % 4) % 4), '=');
return Convert.FromBase64String(base64);
}
// --- The End ---
}
}
@@ -1,26 +1,24 @@
using Sarand.Net.Standard.Base.Core; using Sarand.Net.Standard.Base.Core;
using Sarand.Net.Standard.Base.Utils; using Sarand.Net.Standard.Base.Utils;
using System; using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text; using System.Text;
namespace Sarand.Net.Standard.Base.Helpers namespace Sarand.Net.Standard.Base.Helpers
{ {
//=======================================================================
public static class SacsEncryptionHelper public static class SacsEncryptionHelper
{ {
//------------------------------------------------------------------- // --- Definitions ---
/// <summary>Number of byes in key. [32 bytes = 256 bits]</summary> /// <summary>Number of byes in key. [32 bytes = 256 bits]</summary>
public static int KeyLength { get; } = 32; public static int KeyLength { get; } = 32;
/// <summary>Default encoding: UTF8.</summary> /// <summary>Default encoding: UTF8.</summary>
public static Encoding DefaultEncoding { get; } = Encoding.UTF8; public static Encoding DefaultEncoding { get; } = Encoding.UTF8;
//------------------------------------------------------------------- // --- Key Management ---
// Symmetric Encryption in .NET Core // Symmetric Encryption in .NET Core
// System.Security.Cryptography implements and provides the APIs for encryption in .NET Core. // System.Security.Cryptography implements and provides the APIs for encryption in .NET Core.
// Ref. https://damienbod.com/2020/08/19/symmetric-and-asymmetric-encryption-in-net-core/ // Ref. https://damienbod.com/2020/08/19/symmetric-and-asymmetric-encryption-in-net-core/
//-------------------------------------------------------------------
public static bool IsBase64(String value, int expectedLength = 0) public static bool IsBase64(String value, int expectedLength = 0)
{ {
try try
@@ -46,48 +44,19 @@ namespace Sarand.Net.Standard.Base.Helpers
return Convert.ToBase64String(encoding.GetBytes(plainKey)); return Convert.ToBase64String(encoding.GetBytes(plainKey));
} }
//------------------------------------------------------------------- // Generate random key.
// Generate random key and IV.
// NB Can use own key if suitable - 32 character value in base-64 format. // NB Can use own key if suitable - 32 character value in base-64 format.
public static String InitSymmetricEncryptionKey() public static String InitSymmetricEncryptionKey()
{ {
var key = GetEncodedRandomString(KeyLength); // 256 bits. var key = GetEncodedRandomString(KeyLength); // 256 bits.
return key; return key;
} }
public static string InitSymmetricEncryptionIV(String key)
{
using Aes cipher = CreateCipher(key);
var IVBase64 = Convert.ToBase64String(cipher.IV);
return IVBase64;
}
public static (string Key, string IVBase64) InitSymmetricEncryptionKeyIV()
{
var key = InitSymmetricEncryptionKey();
var IVBase64 = InitSymmetricEncryptionIV(key);
return (key, IVBase64);
}
//-------------------------------------------------------------------
public static string GetEncodedRandomString(int length) public static string GetEncodedRandomString(int length)
{ {
var base64 = Convert.ToBase64String(GenerateRandomBytes(length)); var base64 = Convert.ToBase64String(GenerateRandomBytes(length));
return base64; return base64;
} }
private static Aes CreateCipher(string keyBase64)
{
// Default values: Keysize 256 bits, Padding PKC27.
Aes cipher = Aes.Create();
// Ensure the integrity of the ciphertext if using CBC
cipher.Mode = CipherMode.CBC;
cipher.Padding = PaddingMode.ISO10126;
byte[] rawKey = Convert.FromBase64String(keyBase64);
cipher.Key = rawKey;
return cipher;
}
private static byte[] GenerateRandomBytes(int length) private static byte[] GenerateRandomBytes(int length)
{ {
var byteArray = new byte[length]; var byteArray = new byte[length];
@@ -95,33 +64,18 @@ namespace Sarand.Net.Standard.Base.Helpers
return byteArray; return byteArray;
} }
//------------------------------------------------------------------- // --- Authenticated Encryption ---
public static string Encrypt(string text, string IV, string key)
public static String EncryptAuthenticated(String text, String key, String purpose)
{ {
using Aes cipher = CreateCipher(key); return SacsAuthenticatedEncryption.Encrypt(text, Convert.FromBase64String(key), purpose);
cipher.IV = Convert.FromBase64String(IV);
using ICryptoTransform cryptTransform = cipher.CreateEncryptor();
byte[] plaintext = Encoding.UTF8.GetBytes(text);
byte[] cipherText = cryptTransform.TransformFinalBlock(plaintext, 0, plaintext.Length);
return Convert.ToBase64String(cipherText);
} }
//------------------------------------------------------------------- public static String DecryptAuthenticated(String encryptedText, String key, String purpose)
public static string Decrypt(string encryptedText, string IV, string key)
{ {
using Aes cipher = CreateCipher(key); return SacsAuthenticatedEncryption.Decrypt(encryptedText, Convert.FromBase64String(key), purpose);
cipher.IV = Convert.FromBase64String(IV);
using ICryptoTransform cryptTransform = cipher.CreateDecryptor();
byte[] encryptedBytes = Convert.FromBase64String(encryptedText);
byte[] plainBytes = cryptTransform.TransformFinalBlock(encryptedBytes, 0, encryptedBytes.Length);
return Encoding.UTF8.GetString(plainBytes);
} }
//------------------------------------------------------------------- // --- The End ---
} }
//=======================================================================
} }
+1
View File
@@ -8,6 +8,7 @@ An optional informational overview of sibling workspaces is available in `../../
- `topics.md` tracks prioritised work, work in progress, and completed work. - `topics.md` tracks prioritised work, work in progress, and completed work.
- `component-reviews.md` records which projects, paths, and classes have been reviewed. - `component-reviews.md` records which projects, paths, and classes have been reviewed.
- `authenticated-encryption-migration.md` proposes the shared versioned authenticated-encryption design and migration sequence.
- `legacy-xamarin.md` explains how the retained Xamarin source is isolated from the supported build. - `legacy-xamarin.md` explains how the retained Xamarin source is isolated from the supported build.
## Project Trackers ## Project Trackers
+100
View File
@@ -0,0 +1,100 @@
# Authenticated Encryption Migration
<style>
table th,
table td {
vertical-align: top;
}
</style>
Date reviewed: 2026-09-16
## Implemented Clean-Break Decision
The migration is implemented as a deliberate clean break. Common now writes and reads only the `SACSENC2.` AES-GCM envelope, using a fresh random 12-byte nonce, a 16-byte authentication tag, and required purpose-bound associated data. The nonce is the GCM equivalent of an IV: it is stored with the ciphertext, is not secret, and must never repeat with the same key.
The existing in-code key strings are retained as agreed. `SacsAesGcmCrypter` derives a 256-bit key from its existing string with SHA-256; raw-key consumers continue to supply their existing Base64-encoded keys. Fixed IVs, per-session IV state, CBC operations, and legacy decryption have been removed from active APIs. Invalid, tampered, wrong-key, wrong-purpose, and legacy values fail closed rather than being returned as plaintext.
Existing encrypted cookies, links, session parameters, and service messages are intentionally invalidated. Coupled service clients and servers must be deployed together. Moving keys into protected configuration and adding key identifiers and rotation remains a future hardening task, not part of this compatibility-free change.
Tests cover empty, ASCII, and Unicode round trips; URL-safe envelopes; nonce uniqueness; tampering; wrong keys and purposes; malformed, incomplete, oversized, and legacy input; invalid key sizes; and both shared wrappers.
## Superseded Staged-Migration Proposal
The remainder of this document records the earlier compatibility-preserving proposal for context. Its dual-read, retention-window, key-ring, and staged-rollout requirements were superseded by the clean-break decision above and are not implementation instructions.
## Scope
This document proposed the shared design for migrating the former `SacsAesIvCrypter` API, now replaced by `SacsAesGcmCrypter`, and `SacsEncryptionHelper` from unauthenticated AES-CBC to versioned authenticated encryption. BusinessMgr-specific consumers and retention requirements are recorded in its repository-owned [`docs/encryption-consumers.md`](../../../BusinessMgr/Workspace/docs/encryption-consumers.md).
The recent static-PBKDF2 change deliberately preserved the legacy format. It removed the .NET 10 warning but did not address CBC integrity, fixed password salt, low iteration count, embedded application keys, or key rotation.
## Current Formats
| API | Key material | IV handling | Serialized form | Compatibility concern |
|---|---|---|---|---|
| Former `SacsAesIvCrypter` string API | Password converted to 256-bit key with PBKDF2-SHA256, a fixed eight-byte salt, and 300 iterations | Random 16-byte IV appended to each value | `<base64 ciphertext>;<base64 IV>` | Used for BusinessMgr service request and response traffic. Existing clients and servers must continue to understand the legacy form during a coordinated rollout. |
| `SacsEncryptionHelper` | Caller supplies a Base64-encoded 256-bit key | Caller supplies a 16-byte IV; several BusinessMgr flows reuse a fixed IV | Base64 ciphertext only | Used for cookies, emailed links, and session URLs. The IV and key are external to the value, so the ciphertext is not self-describing and cannot support algorithm or key rotation safely. |
Both formats use AES-CBC without an authentication tag. Successful decryption therefore does not prove that the ciphertext and associated context are authentic.
## Proposed Version 2 Envelope
Use AES-GCM through .NET's `AesGcm` API with a fresh random 12-byte nonce and a 16-byte authentication tag for every encryption operation. Never reuse a nonce with the same key.
The serialized value should be URL-safe and self-describing:
```text
SACSENC2.<key-id>.<kdf-id>.<kdf-parameters>.<base64url nonce>.<base64url ciphertext>.<base64url tag>
```
The exact binary/text encoding should be finalised with test vectors before implementation. Required properties are:
- An unambiguous version marker that cannot be confused with either legacy format.
- A non-secret key identifier so decryptors can select the correct key during rotation.
- Explicit KDF identity and parameters for password-derived keys; raw-key callers use a `none` KDF marker.
- A fresh 12-byte nonce and a 16-byte tag stored with every ciphertext.
- Optional authenticated associated data identifying the application and purpose, such as `BusinessMgr:Control:KeepLoggedIn`, to prevent a valid value being replayed in another context.
- Base64url encoding for cookies and query-string values without additional URL-encoding ambiguity.
Password-based encryption must use a new random salt per value and a separately agreed, benchmarked PBKDF2 iteration count. Legacy fixed-salt/300-iteration derivation remains read-only compatibility code and must not be relabelled as version 2.
## API Shape
Keep legacy methods available for compatibility, but prevent new callers from accidentally creating legacy ciphertext. The implementation should introduce purpose-aware versioned operations along these lines:
```csharp
String EncryptVersioned(String plaintext, EncryptionKey key, String purpose);
Boolean TryDecryptVersioned(String value, EncryptionKeyRing keys, String purpose, out String plaintext, out EncryptionFormat format);
```
`EncryptionFormat` would have distinguished authenticated version 2, the former `SacsAesIvCrypter` format, legacy `SacsEncryptionHelper`, and unencrypted input. Password-based and raw-key entry points could have remained separate internally, but both would have emitted the same versioned envelope rules.
Authentication failures must return a distinct failure result, must not return attacker-controlled plaintext, and must be logged without recording keys, ciphertext, cookies, tokens, or decrypted personal data.
## Rollout Sequence
1. Add envelope parsing, AES-GCM encryption/decryption, deterministic test vectors, tamper tests for every envelope field, wrong-key/purpose tests, and legacy-read tests in Common.
2. Introduce configuration-backed key rings with an active write key and retained read keys. Do not commit production keys.
3. Add dual-read behavior while retaining legacy writes; deploy it to every producer and consumer of a shared format.
4. Switch one consumer category at a time to version 2 writes. New writes use AES-GCM; reads dispatch by the envelope marker and continue to accept the applicable legacy format.
5. Retire legacy reads only after the category-specific retention window and rollout constraints in BusinessMgr's inventory have elapsed.
6. Remove embedded keys and fixed IVs after configured key deployment is verified.
## Required Tests
| Area | Required coverage |
|---|---|
| Envelope | Version parsing, Base64url round trip, empty and maximum expected plaintext, Unicode, unknown versions, missing/extra fields, and size limits before allocation. |
| AES-GCM | Fixed test vector, random nonce uniqueness, modified nonce/ciphertext/tag rejection, wrong key, wrong purpose/associated data, and unsupported platform behavior. |
| Password derivation | Fixed version 2 vector, random salt uniqueness, parameter bounds, wrong password, and exact legacy ciphertext compatibility. |
| Key rotation | Active-key writes, retained-key reads, unknown and retired key identifiers, and no fallback from a failed authenticated value to legacy parsing. |
| Consumers | Legacy and version 2 cookies/links/service messages, expiration handling, mixed-version service rollout, and failure behavior that never treats invalid ciphertext as plaintext. |
## Decisions Required Before Implementation
- Where each application obtains its encryption key ring and how keys are deployed and rotated.
- Whether service request and response traffic can move in one coordinated release or needs explicit capability negotiation.
- The maximum lifetime of login cookies and emailed links, which determines how long legacy reads remain.
- The purpose strings and key separation required for Control, Portal, service requests, service responses, password reset, demo registration, and session parameters.
- The benchmarked PBKDF2 iteration count if password-derived version 2 encryption remains necessary; configured random keys are preferable for server applications.
+2 -2
View File
@@ -25,5 +25,5 @@ Last updated: 2026-09-16
| `SacsStandardBase/Base/Net/SacsHttpClient.cs` | Class | Zstandard dependency upgraded and covered | 2026-09-15 | Retain the loopback response test for future HTTP and ZstdNet updates. | ZstdNet 1.5.7 replaces 1.4.5. An in-process HTTP test exercises the real response path with native Zstandard compression and verifies successful UTF-8 decompression without external network access. | | `SacsStandardBase/Base/Net/SacsHttpClient.cs` | Class | Zstandard dependency upgraded and covered | 2026-09-15 | Retain the loopback response test for future HTTP and ZstdNet updates. | ZstdNet 1.5.7 replaces 1.4.5. An in-process HTTP test exercises the real response path with native Zstandard compression and verifies successful UTF-8 decompression without external network access. |
| `SacsStandardProc/Proc/Excel/SaMsSpreadsheet_OpenXml.cs` | Class | Dependency upgrade complete; implementation remains incomplete | 2026-09-15 | Scope functional completion and dedicated wrapper tests separately if this implementation is to be revived. | DocumentFormat.OpenXml 3.5.1 builds and passes the existing spreadsheet and downstream validation. The Open XML implementation contains numerous pre-existing unsupported or placeholder operations, so this update does not present it as production-complete. | | `SacsStandardProc/Proc/Excel/SaMsSpreadsheet_OpenXml.cs` | Class | Dependency upgrade complete; implementation remains incomplete | 2026-09-15 | Scope functional completion and dedicated wrapper tests separately if this implementation is to be revived. | DocumentFormat.OpenXml 3.5.1 builds and passes the existing spreadsheet and downstream validation. The Open XML implementation contains numerous pre-existing unsupported or placeholder operations, so this update does not present it as production-complete. |
| `SacsStandardProc/Proc/Excel/SaMsSpreadsheet_POI.cs` | Class | Dependency upgrade complete | 2026-09-12 | Retain the focused spreadsheet suite for future NPOI updates; do not adopt NPOI 2.8.0 without an explicit licensing decision. | NPOI 2.7.6 is paired with an explicit `System.Security.Cryptography.Xml` 10.0.12 reference and NuGet reports no vulnerable packages for `SacsStandardProc`. Nullable date and indexed-colour API changes were adapted. Ten focused tests and all 111 Common tests pass; BusinessMgr builds and its 21 tests pass. | | `SacsStandardProc/Proc/Excel/SaMsSpreadsheet_POI.cs` | Class | Dependency upgrade complete | 2026-09-12 | Retain the focused spreadsheet suite for future NPOI updates; do not adopt NPOI 2.8.0 without an explicit licensing decision. | NPOI 2.7.6 is paired with an explicit `System.Security.Cryptography.Xml` 10.0.12 reference and NuGet reports no vulnerable packages for `SacsStandardProc`. Nullable date and indexed-colour API changes were adapted. Ten focused tests and all 111 Common tests pass; BusinessMgr builds and its 21 tests pass. |
| `SacsStandardBase/Base/Helpers/SacsEncryptionHelper.cs` and `SacsStandardBase/Base/Data/SacsAesIvCrypter.cs` | Encryption compatibility and hardening | Compatibility warning resolved; architectural action still required | 2026-09-16 | Design a versioned authenticated-encryption format with legacy migration and tamper tests. | Static PBKDF2 now preserves the legacy fixed-salt, 300-iteration SHA-256 derivation without the .NET 10 warning, and fixed legacy ciphertext protects compatibility. Nineteen focused tests cover both helpers. CBC remains unauthenticated, and the password-based format still uses a fixed salt and low iteration count; changing those properties requires a versioned migration rather than an in-place update. | | `SacsAuthenticatedEncryption`, `SacsEncryptionHelper`, and `SacsAesGcmCrypter` | Authenticated-encryption migration | Clean-break migration implemented | 2026-09-16 | Retain the focused suite; later move embedded application keys to protected configuration and add key rotation. | The `SACSENC2.` envelope uses AES-GCM with a fresh 12-byte nonce, a 16-byte tag, and purpose-bound associated data. Existing in-code keys are retained by decision, while CBC, fixed/session IVs, legacy reads, and plaintext fallback are removed. Thirty focused tests pass. See [`authenticated-encryption-migration.md`](authenticated-encryption-migration.md). |
| `eng/validate.ps1` | Repository validation | Complete; passing | 2026-09-16 | Use as the standard validation entry point and extend it when repository-wide checks change. | Builds serially, runs the 11-test focused SQL/ODBC-provider suite, the 10-test focused NPOI suite, and all 167 Common tests; audits both package-owning projects; verifies changed-file line endings and diffs; then invokes BusinessMgr validation. | | `eng/validate.ps1` | Repository validation | Complete; passing | 2026-09-16 | Use as the standard validation entry point and extend it when repository-wide checks change. | Builds serially, runs the 11-test focused SQL/ODBC-provider suite, the 10-test focused NPOI suite, and all 178 Common tests; audits both package-owning projects; verifies changed-file line endings and diffs; then invokes BusinessMgr validation. |
+3 -2
View File
@@ -13,7 +13,7 @@ Last updated: 2026-09-16
| Priority | Topic | Date noted | Next step | Notes | | Priority | Topic | Date noted | Next step | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| P1 | Design a versioned authenticated-encryption migration | 2026-09-08 | Inventory persisted and transient ciphertext consumers, define an AES-GCM envelope, and specify legacy-read/new-write migration behavior before implementation. | The compatibility-preserving PBKDF2 warning fix and direct regression coverage are complete. Existing CBC formats are unauthenticated; the password-derived format also uses a fixed salt and 300 iterations. An in-place algorithm change would make existing data unreadable. | | P2 | Externalise and rotate encryption keys | 2026-09-16 | Move the retained in-code application keys to protected configuration or a secret store, then add key identifiers and a rotation policy in a separately scoped change. | The agreed clean-break AES-GCM migration is complete and intentionally retains the existing key strings. This follow-up is defense-in-depth and requires an operational key-deployment decision. |
| P3 | Review raster image handling and ImageSharp | 2026-09-16 | Retain ImageSharp 3.1.12 for now and revisit the wrapper, licensing, and alternatives as a separately scoped functional review. | Version 3.1.12 has no reported vulnerability and five focused tests cover the current narrow use. Later options are: obtain an eligible free or commercial licence and upgrade to ImageSharp 4.1.2; retain a supported secure 3.x release; migrate to MIT-licensed SkiaSharp, accepting native runtime assets and a wrapper rewrite; or evaluate Magick.NET/ImageMagick as a candidate with broad image-format and processing support but native binaries and a heavier dependency footprint. This review is separate from BusinessMgr's Windows printer integration. | | P3 | Review raster image handling and ImageSharp | 2026-09-16 | Retain ImageSharp 3.1.12 for now and revisit the wrapper, licensing, and alternatives as a separately scoped functional review. | Version 3.1.12 has no reported vulnerability and five focused tests cover the current narrow use. Later options are: obtain an eligible free or commercial licence and upgrade to ImageSharp 4.1.2; retain a supported secure 3.x release; migrate to MIT-licensed SkiaSharp, accepting native runtime assets and a wrapper rewrite; or evaluate Magick.NET/ImageMagick as a candidate with broad image-format and processing support but native binaries and a heavier dependency footprint. This review is separate from BusinessMgr's Windows printer integration. |
| P1 | Prevent synchronous and asynchronous execution from overlapping in `SaProcess_Base` | 2026-09-09 | Route `RunProcess()` and `RunAsync()` through one guarded start operation and extend the lifecycle tests. | A synchronous call can currently reuse and dispose the asynchronous worker's task data. | | P1 | Prevent synchronous and asynchronous execution from overlapping in `SaProcess_Base` | 2026-09-09 | Route `RunProcess()` and `RunAsync()` through one guarded start operation and extend the lifecycle tests. | A synchronous call can currently reuse and dispose the asynchronous worker's task data. |
| P2 | Retain cancellation as a completed process outcome | 2026-09-09 | Store a durable cancellation result until the next process reset and test post-completion state. | `IsInterrupted` currently loses token-based cancellation after task-data cleanup. | | P2 | Retain cancellation as a completed process outcome | 2026-09-09 | Store a durable cancellation result until the next process reset and test post-completion state. | `IsInterrupted` currently loses token-based cancellation after task-data cleanup. |
@@ -45,7 +45,8 @@ Last updated: 2026-09-16
| Topic | Date noted | Date started | Date completed | Outcome | | Topic | Date noted | Date started | Date completed | Outcome |
|---|---|---|---|---| |---|---|---|---|---|
| Remove the obsolete PBKDF2 usage and establish encryption compatibility coverage | 2026-09-10 | 2026-09-16 | 2026-09-16 | Replaced the obsolete `Rfc2898DeriveBytes` constructor with static `Pbkdf2` using the same password, fixed salt, 300 iterations, SHA-256, and key length. Fixed legacy ciphertext proves derived-key and stored-data compatibility. Expanded direct coverage across both encryption helpers, corrected whitespace-only key acceptance, and disposed AES and transform resources. The broader versioned authenticated-encryption migration remains separately tracked. | | Replace legacy CBC encryption with authenticated encryption | 2026-09-08 | 2026-09-16 | 2026-09-16 | Implemented the `SACSENC2.` AES-GCM envelope with fresh nonces, 16-byte authentication tags, and purpose-bound associated data. Removed active CBC, fixed/session IVs, legacy reads, and plaintext fallback. Retained existing in-code keys by explicit decision. Thirty focused Common tests and BusinessMgr consumer tests cover the new behavior; see [`authenticated-encryption-migration.md`](authenticated-encryption-migration.md). |
| Remove the obsolete PBKDF2 usage and establish encryption compatibility coverage | 2026-09-10 | 2026-09-16 | 2026-09-16 | Replaced the obsolete `Rfc2898DeriveBytes` constructor with static `Pbkdf2` using the same password, fixed salt, 300 iterations, SHA-256, and key length. Fixed legacy ciphertext proved derived-key and stored-data compatibility before the subsequent clean-break authenticated-encryption migration replaced that legacy format. Expanded direct coverage across both encryption helpers, corrected whitespace-only key acceptance, and disposed AES and transform resources. |
| Harden and cover `AssemblyUtil` PE timestamp handling | 2026-09-10 | 2026-09-16 | 2026-09-16 | Replaced the unchecked partial reads and marshalled COFF structure with exact primitive reads, PE-signature validation, and graceful handling of missing, malformed, or truncated files. Added a public `TryGetBuildDateTime` extension while preserving `GetBuildDateTime`, plus five focused tests. The API documentation notes that deterministic-build COFF timestamps need not represent physical build time. | | Harden and cover `AssemblyUtil` PE timestamp handling | 2026-09-10 | 2026-09-16 | 2026-09-16 | Replaced the unchecked partial reads and marshalled COFF structure with exact primitive reads, PE-signature validation, and graceful handling of missing, malformed, or truncated files. Added a public `TryGetBuildDateTime` extension while preserving `GetBuildDateTime`, plus five focused tests. The API documentation notes that deterministic-build COFF timestamps need not represent physical build time. |
| Upgrade Microsoft.Data.SqlClient and expand SQL provider coverage | 2026-09-15 | 2026-09-15 | 2026-09-15 | Updated Microsoft.Data.SqlClient from 5.2.3 to 7.0.3. Expanded the database-free provider suite from eight to eleven tests, adding parsed SQL connection security and authentication settings plus complete SQL scalar and typed-null parameter coverage. The tests explicitly verify SqlClient 7's mandatory-encryption option. Full Common and downstream BusinessMgr validation passes. | | Upgrade Microsoft.Data.SqlClient and expand SQL provider coverage | 2026-09-15 | 2026-09-15 | 2026-09-15 | Updated Microsoft.Data.SqlClient from 5.2.3 to 7.0.3. Expanded the database-free provider suite from eight to eleven tests, adding parsed SQL connection security and authentication settings plus complete SQL scalar and typed-null parameter coverage. The tests explicitly verify SqlClient 7's mandatory-encryption option. Full Common and downstream BusinessMgr validation passes. |
| Align System.Data.Odbc with .NET 10 and expand provider coverage | 2026-09-15 | 2026-09-15 | 2026-09-15 | Updated the Common ODBC dependency from 6.0.2 to 10.0.12 and removed six redundant direct references from BusinessMgr consumers. Expanded the database-free provider suite from four to eight tests, covering non-SQL connections, adapter creation, parameter order, scalar types, and typed nulls. The tests exposed and prompted correction of missing explicit `Int64`, `Decimal`, `Boolean`, `Guid`, and string mappings. All 141 Common tests and both BusinessMgr suites pass. | | Align System.Data.Odbc with .NET 10 and expand provider coverage | 2026-09-15 | 2026-09-15 | 2026-09-15 | Updated the Common ODBC dependency from 6.0.2 to 10.0.12 and removed six redundant direct references from BusinessMgr consumers. Expanded the database-free provider suite from four to eight tests, covering non-SQL connections, adapter creation, parameter order, scalar types, and typed nulls. The tests exposed and prompted correction of missing explicit `Int64`, `Decimal`, `Boolean`, `Guid`, and string mappings. All 141 Common tests and both BusinessMgr suites pass. |