diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/CryptographyService.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/CryptographyService.cs index f60586df..cfcfd159 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/CryptographyService.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/CryptographyService.cs @@ -17,7 +17,7 @@ public byte[] EncryptBytes(byte[] input, EncryptionAlgorithm algorithm, Symmetri { ArgumentNullException.ThrowIfNull(input); ArgumentNullException.ThrowIfNull(options); - ValidateSymmetric(algorithm, options, options.IV); + ValidateSymmetric(algorithm, options, options.IV, isDecrypt: false); return options.Key.UseKeyBytes(keyBytes => SymmetricInteropHelper.DispatchEncrypt(algorithm, options.Format, options.KdfIterations, keyBytes, options.IV, input, options.AesKeySize)); } @@ -26,7 +26,7 @@ public byte[] DecryptBytes(byte[] input, EncryptionAlgorithm algorithm, Symmetri { ArgumentNullException.ThrowIfNull(input); ArgumentNullException.ThrowIfNull(options); - ValidateSymmetric(algorithm, options, iv: null); + ValidateSymmetric(algorithm, options, iv: null, isDecrypt: true); return options.Key.UseKeyBytes(keyBytes => SymmetricInteropHelper.DispatchDecrypt(algorithm, options.Format, options.KdfIterations, keyBytes, input, options.AesKeySize)); } @@ -305,12 +305,14 @@ public bool PgpVerifyPublicKey(PgpPublicKey key) // enforced at compile time by the typed factory parameters on SymmetricEncryptOptions / // SymmetricDecryptOptions; this still catches KDF-iteration-out-of-bounds, raw-key // length mismatch, and (encrypt) IV-on-non-Raw-format. iv is null for decrypt. - private static void ValidateSymmetric(EncryptionAlgorithm algorithm, CryptoOptions options, byte[] iv) + // isDecrypt skips the encrypt-only positive-but-below-minimum KDF-iteration floor so a + // third-party blob produced with a low iteration count can still be decrypted (STUD-80534). + private static void ValidateSymmetric(EncryptionAlgorithm algorithm, CryptoOptions options, byte[] iv, bool isDecrypt) { CryptoKey key = options.Key ?? throw new ArgumentException("Options must carry a key (construct via a format factory).", nameof(options)); string ivSentinel = iv != null && iv.Length > 0 ? "set" : null; int? rawKeyLength = key.IsRawKey ? key.KeyBytes.Length : (int?)null; - SymmetricInteropHelper.ValidateInteropSettings(algorithm, options.Format, key.BytesFormat, ivSentinel, options.KdfIterations, rawKeyLength); + SymmetricInteropHelper.ValidateInteropSettings(algorithm, options.Format, key.BytesFormat, ivSentinel, options.KdfIterations, rawKeyLength, isDecrypt); } private static string ComputeHashHex(KeyedHashAlgorithms algorithm, byte[] inputBytes, byte[] keyBytes) diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropInProcessTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropInProcessTests.cs index 5def6567..742b6e5c 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropInProcessTests.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropInProcessTests.cs @@ -77,6 +77,59 @@ public void OpenSslEnc_AesCbc_InternalToExternal(int iterations, AesKeySize aesK Assert.Equal(Plaintext, Encoding.UTF8.GetString(decrypted)); } + // ──────────────────────────────────────────────────────────────────────── + // STUD-80534 — the MinKdfIterations=1000 floor is an encrypt-only guard. A + // third-party blob produced with a low iteration count (e.g. + // `openssl enc -pbkdf2 -iter 100`) is mathematically decryptable, so decrypt + // must honour whatever the producer chose; encrypt must still refuse to emit + // weak ciphertext. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void OpenSslEnc_AesCbc_DecryptHonoursLowKdfIterations() + { + // Reproducer: an external producer used iterations=100 (below the 1000 floor). We can + // derive the matching key/IV, so the round-trip must succeed. Before the fix this threw + // ArgumentException (Validation_KdfIterations_BelowMinimum) before reaching the decrypt code. + const int lowIterations = 100; + byte[] plainBytes = Encoding.UTF8.GetBytes(Plaintext); + byte[] externalBlob = BclOpenSslEnc.EncryptAesCbc(plainBytes, Password, lowIterations, 32); + + string decrypted = RunDecryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, input: Convert.ToBase64String(externalBlob), + inputEncoding: Encoding.UTF8, iterations: lowIterations, aesKeySize: AesKeySize.Aes256); + + Assert.Equal(Plaintext, decrypted); + } + + [Fact] + public void OpenSslEnc_AesCbc_EncryptStillRefusesLowKdfIterations() + { + // Encrypt-side floor pin: we must never produce ciphertext with a weak iteration count. + Assert.Throws(() => RunEncryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, inputEncoding: Encoding.UTF8, iterations: 100, aesKeySize: AesKeySize.Aes256)); + } + + [Fact] + public void OpenSslEnc_AesCbc_NegativeKdfIterations_RejectedOnBothDirections() + { + // Negative iterations have no legitimate use case and stay rejected regardless of direction. + // On decrypt the validator runs before the input blob is decoded, so the input is never read; + // and a junk-blob failure would surface as InvalidOperationException, not ArgumentException — + // asserting ArgumentException therefore pins the rejection to the iteration validator alone. + Assert.Throws(() => RunEncryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, inputEncoding: Encoding.UTF8, iterations: -1, aesKeySize: AesKeySize.Aes256)); + + byte[] validBlob = BclOpenSslEnc.EncryptAesCbc(Encoding.UTF8.GetBytes(Plaintext), Password, 600_000, 32); + Assert.Throws(() => RunDecryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, input: Convert.ToBase64String(validBlob), + inputEncoding: Encoding.UTF8, iterations: -1, aesKeySize: AesKeySize.Aes256)); + } + // ──────────────────────────────────────────────────────────────────────── // STUD-80530 — plaintext encoding must be governed by PlaintextEncoding, NOT the // key/password Encoding. These two tests would pass against a UiPath↔UiPath round-trip diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropHelperTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropHelperTests.cs index a4394ed8..87a5f343 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropHelperTests.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropHelperTests.cs @@ -90,6 +90,26 @@ public void Validate_KdfIterations_AtFloor(int iterations, bool shouldThrow) else Should.NotThrow(act); } + // STUD-80534: the positive-but-below-minimum floor is an encrypt-only guard. On decrypt + // (isDecrypt: true) a low iteration count chosen by a third-party producer must be honoured, + // so 999 no longer throws. Negative iterations have no legitimate use case and stay rejected + // in both directions; zero still means "use the format default" in both directions. + [Theory] + [InlineData(999, false)] + [InlineData(1_000, false)] + [InlineData(0, false)] + [InlineData(-1, true)] + [InlineData(int.MinValue, true)] + public void Validate_KdfIterations_AtFloor_DecryptSkipsPositiveFloor(int iterations, bool shouldThrow) + { + Action act = () => SymmetricInteropHelper.ValidateInteropSettings( + EncryptionAlgorithm.AES, SymmetricWireFormat.Owasp2026, KeyBytesFormat.Encoded, + ivString: null, kdfIterations: iterations, rawKeyLengthBytes: null, isDecrypt: true); + + if (shouldThrow) Should.Throw(act); + else Should.NotThrow(act); + } + [Fact] public void Validate_Raw_WithWrongKeyLength_ThrowsWithLegalSizesInMessage() { diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptFile.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptFile.cs index d77005a0..edb2d360 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptFile.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptFile.cs @@ -301,7 +301,8 @@ private byte[] ExecuteSymmetricDecrypt(CodeActivityContext context, byte[] encry { throw new InvalidOperationException(Resources.GenericCryptographicException, ex); } - }); + }, + isDecrypt: true); } private void WriteDecryptedOutput(CodeActivityContext context, string outputFilePath, byte[] decrypted, (string, string, string) result) diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptText.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptText.cs index f701a3b4..1c232cb0 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptText.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptText.cs @@ -261,7 +261,8 @@ private string ExecuteSymmetricDecrypt(CodeActivityContext context, string input { throw new InvalidOperationException(Resources.GenericCryptographicException, ex); } - }); + }, + isDecrypt: true); return plaintextEncoding.GetString(decrypted); } diff --git a/Activities/Cryptography/UiPath.Cryptography/SymmetricInteropHelper.cs b/Activities/Cryptography/UiPath.Cryptography/SymmetricInteropHelper.cs index a02546d0..50cc1eb9 100644 --- a/Activities/Cryptography/UiPath.Cryptography/SymmetricInteropHelper.cs +++ b/Activities/Cryptography/UiPath.Cryptography/SymmetricInteropHelper.cs @@ -30,7 +30,8 @@ public static void ValidateInteropSettings( KeyBytesFormat keyFormat, string ivString, int kdfIterations, - int? rawKeyLengthBytes) + int? rawKeyLengthBytes, + bool isDecrypt = false) { if (format == SymmetricWireFormat.Raw && keyFormat == KeyBytesFormat.Encoded) throw new ArgumentException(Resources.Validation_RawKeyFormat_EncodedNotAllowed); @@ -44,7 +45,10 @@ public static void ValidateInteropSettings( throw new ArgumentException(Resources.Validation_KdfIterations_NotForClassicOrRaw); // Negative iterations were silently swallowed by the dispatch (treated as "use default") — // only zero means "default". Anything below the minimum (including negatives) is invalid. - if (kdfIterations < 0 || (kdfIterations > 0 && kdfIterations < MinKdfIterations)) + // The positive-but-below-minimum floor is an encrypt-only guard (never produce weak ciphertext); + // on decrypt we must honour whatever iteration count a third-party producer chose, so the floor + // is skipped when isDecrypt is true. Negative iterations stay rejected in both directions. + if (kdfIterations < 0 || (kdfIterations > 0 && kdfIterations < MinKdfIterations && !isDecrypt)) throw new ArgumentException(string.Format(Resources.Validation_KdfIterations_BelowMinimum, kdfIterations, MinKdfIterations)); if (format == SymmetricWireFormat.Raw && rawKeyLengthBytes.HasValue) @@ -137,11 +141,12 @@ public static TOut RunSymmetricWithKeyLifecycle( string ivString, int kdfIterations, bool needsIv, - Func dispatch) + Func dispatch, + bool isDecrypt = false) { if (dispatch == null) throw new ArgumentNullException(nameof(dispatch)); - ValidateInteropSettings(algorithm, format, keyFormat, ivString, kdfIterations, null); + ValidateInteropSettings(algorithm, format, keyFormat, ivString, kdfIterations, null, isDecrypt); byte[] keyOrPasswordBytes = ParseKeyOrIv(keyString, keySecureString, keyFormat, keyEncoding); byte[] ivBytes = needsIv @@ -149,7 +154,7 @@ public static TOut RunSymmetricWithKeyLifecycle( : null; if (format == SymmetricWireFormat.Raw) - ValidateInteropSettings(algorithm, format, keyFormat, ivString, kdfIterations, keyOrPasswordBytes?.Length); + ValidateInteropSettings(algorithm, format, keyFormat, ivString, kdfIterations, keyOrPasswordBytes?.Length, isDecrypt); try {