diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/CryptographyServiceTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/CryptographyServiceTests.cs index b8be5d8cb..a1fe1cf51 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/CryptographyServiceTests.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/CryptographyServiceTests.cs @@ -11,36 +11,38 @@ namespace UiPath.Cryptography.Activities.API.Tests { /// /// xUnit class fixture: generates a single PGP key pair (RSA-2048 for test speed) once - /// per test class and exposes the bytes + paths so PGP roundtrip tests don't pay the - /// multi-second key-generation cost per case. + /// per test class and exposes the keys + their armored bytes so PGP roundtrip tests don't + /// pay the multi-second key-generation cost per case. /// public sealed class PgpKeyFixture : IDisposable { public const string Passphrase = "fixture-pass"; - public byte[] PublicKey { get; } - public byte[] PrivateKey { get; } + public PgpKeyPair KeyPair { get; } + public PgpPublicKey PublicKey => KeyPair.PublicKey; + public PgpPrivateKey PrivateKey => KeyPair.PrivateKey; +#pragma warning disable CA1819 // Test fixture: returning byte[] directly is intentional — tests feed these straight into File.WriteAllBytes / PgpPublicKey.FromBytes. + public byte[] PublicKeyBytes { get; } + public byte[] PrivateKeyBytes { get; } +#pragma warning restore CA1819 public string PublicKeyArmored { get; } - public string PrivateKeyArmored { get; } public string PublicKeyPath { get; } - public string PrivateKeyPath { get; } public PgpKeyFixture() { var service = new CryptographyService(); - PublicKeyPath = Path.Combine(Path.GetTempPath(), $"cryptosvc_pub_{Guid.NewGuid()}.asc"); - PrivateKeyPath = Path.Combine(Path.GetTempPath(), $"cryptosvc_priv_{Guid.NewGuid()}.asc"); - service.PgpGenerateKeys(PublicKeyPath, PrivateKeyPath, "Service Tester ", Passphrase, RsaKeySize.Rsa2048); - PublicKey = File.ReadAllBytes(PublicKeyPath); - PrivateKey = File.ReadAllBytes(PrivateKeyPath); - PublicKeyArmored = Encoding.UTF8.GetString(PublicKey); - PrivateKeyArmored = Encoding.UTF8.GetString(PrivateKey); + KeyPair = service.PgpGenerateKeys("Service Tester ", Passphrase, RsaKeySize.Rsa2048); + PublicKeyBytes = PublicKey.ToBytes(); + PrivateKeyBytes = PrivateKey.ToBytes(); + PublicKeyArmored = Encoding.UTF8.GetString(PublicKeyBytes); + PublicKeyPath = Path.Combine(Path.GetTempPath(), $"cryptosvc_pub_{Guid.NewGuid():N}.asc"); + PublicKey.Save(PublicKeyPath, overwrite: true); } public void Dispose() { + PrivateKey.Dispose(); if (File.Exists(PublicKeyPath)) File.Delete(PublicKeyPath); - if (File.Exists(PrivateKeyPath)) File.Delete(PrivateKeyPath); } } @@ -56,190 +58,334 @@ public CryptographyServiceTests(PgpKeyFixture keys) } // ═══════════════════════════════════════════════════════════════════════ - // Symmetric Encrypt / Decrypt — Bytes form + // Symmetric Encrypt / Decrypt — Bytes form, PasswordKey + RawKey // ═══════════════════════════════════════════════════════════════════════ + public static TheoryData AlgorithmsForPasswordKey { get; } = new() + { + { EncryptionAlgorithm.AES, "string" }, + { EncryptionAlgorithm.AES, "secure" }, + { EncryptionAlgorithm.AESGCM, "string" }, + { EncryptionAlgorithm.AESGCM, "secure" }, + }; + [Theory] - [InlineData(EncryptionAlgorithm.AES)] - [InlineData(EncryptionAlgorithm.AESGCM)] - public void Encrypt_Decrypt_StringKey_RoundTrip(EncryptionAlgorithm algorithm) + [MemberData(nameof(AlgorithmsForPasswordKey))] + public void Encrypt_Decrypt_PasswordKey_RoundTrip(EncryptionAlgorithm algorithm, string keyKind) { byte[] plain = Encoding.UTF8.GetBytes("Hello bytes form!"); - byte[] cipher = _service.EncryptBytes(plain, algorithm, "myKey", Encoding.UTF8); - byte[] decrypted = _service.DecryptBytes(cipher, algorithm, "myKey", Encoding.UTF8); + PasswordKey key = NewPasswordKey("myKey", keyKind); + + // Encrypt twice — pins that: + // (a) cipher != plain (encryption actually happened, not a no-op) + // (b) cipher1 != cipher2 (fresh salt/IV per call — pins the IV/salt randomness) + // Without these pins, a no-op `EncryptBytes` that returned its input would still + // pass every round-trip test in this file. + byte[] cipher1 = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.Classic(key)); + byte[] cipher2 = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.Classic(key)); + cipher1.ShouldNotBe(plain); + cipher2.ShouldNotBe(plain); + cipher1.ShouldNotBe(cipher2); + + byte[] decrypted = _service.DecryptBytes(cipher1, algorithm, SymmetricDecryptOptions.Classic(key)); decrypted.ShouldBe(plain); } [Theory] [InlineData(EncryptionAlgorithm.AES)] [InlineData(EncryptionAlgorithm.AESGCM)] - public void Encrypt_Decrypt_SecureStringKey_RoundTrip(EncryptionAlgorithm algorithm) + public void Encrypt_Decrypt_RawKey_Hex_RoundTrip(EncryptionAlgorithm algorithm) { - byte[] plain = Encoding.UTF8.GetBytes("Hello secure bytes!"); - SecureString key = ToSecureString("mySecureKey"); - byte[] cipher = _service.EncryptBytes(plain, algorithm, key, Encoding.UTF8); - byte[] decrypted = _service.DecryptBytes(cipher, algorithm, key, Encoding.UTF8); + byte[] plain = Encoding.UTF8.GetBytes("Raw round-trip"); + byte[] rawKey = MakeDeterministicKey(32, offset: 42); // AES-256, deterministic non-RNG seed + RawKey key = RawKey.FromHex(Convert.ToHexString(rawKey)); + byte[] cipher = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.Raw(key)); + byte[] decrypted = _service.DecryptBytes(cipher, algorithm, SymmetricDecryptOptions.Raw(key)); decrypted.ShouldBe(plain); } [Theory] [InlineData(EncryptionAlgorithm.AES)] - [InlineData(EncryptionAlgorithm.AESGCM)] - public void Encrypt_Decrypt_ByteArrayKey_RoundTrip(EncryptionAlgorithm algorithm) + public void Encrypt_Decrypt_RawKey_Base64_RoundTrip(EncryptionAlgorithm algorithm) { - byte[] plain = Encoding.UTF8.GetBytes("Hello raw key bytes!"); - byte[] key = Encoding.UTF8.GetBytes("rawKeyBytes!"); - byte[] cipher = _service.EncryptBytes(plain, algorithm, key); - byte[] decrypted = _service.DecryptBytes(cipher, algorithm, key); + byte[] plain = Encoding.UTF8.GetBytes("Base64 raw round-trip"); + byte[] rawKey = MakeDeterministicKey(32, offset: 7); + RawKey key = RawKey.FromBase64(Convert.ToBase64String(rawKey)); + byte[] cipher = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.Raw(key)); + byte[] decrypted = _service.DecryptBytes(cipher, algorithm, SymmetricDecryptOptions.Raw(key)); + decrypted.ShouldBe(plain); + } + + [Theory] + [InlineData(EncryptionAlgorithm.AES)] + public void Encrypt_Decrypt_RawKey_Bytes_RoundTrip(EncryptionAlgorithm algorithm) + { + byte[] plain = Encoding.UTF8.GetBytes("FromBytes round-trip"); + byte[] rawKey = MakeDeterministicKey(32, offset: 11); + RawKey key = RawKey.FromBytes(rawKey); + byte[] cipher = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.Raw(key)); + byte[] decrypted = _service.DecryptBytes(cipher, algorithm, SymmetricDecryptOptions.Raw(key)); decrypted.ShouldBe(plain); } [Fact] - public void Encrypt_NullInputBytes_Throws() + public void Encrypt_Decrypt_RawKey_ExplicitIv_RoundTrip() + { + byte[] plain = Encoding.UTF8.GetBytes("Explicit IV roundtrip"); + byte[] rawKey = MakeDeterministicKey(32, offset: 99); + byte[] iv = MakeDeterministicKey(16, offset: 33); + RawKey key = RawKey.FromBytes(rawKey); + byte[] cipher = _service.EncryptBytes(plain, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Raw(key, iv)); + + // Raw wire layout: IV ‖ ct [‖ tag]. Pin that the supplied IV is the one written — + // without this, a regression that ignored `options.IV` and generated a fresh random + // IV would still round-trip (decrypt reads IV from the stream prefix). + cipher.Length.ShouldBeGreaterThanOrEqualTo(iv.Length); + cipher.AsSpan(0, iv.Length).ToArray().ShouldBe(iv); + + byte[] decrypted = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Raw(key)); + decrypted.ShouldBe(plain); + } + + [Fact] + public void Encrypt_Decrypt_Owasp2026_RoundTrip() { - Should.Throw(() => _service.EncryptBytes(null, EncryptionAlgorithm.AES, "k", Encoding.UTF8)); - Should.Throw(() => _service.EncryptBytes(null, EncryptionAlgorithm.AES, ToSecureString("k"), Encoding.UTF8)); - Should.Throw(() => _service.EncryptBytes(null, EncryptionAlgorithm.AES, Encoding.UTF8.GetBytes("k"))); + byte[] plain = Encoding.UTF8.GetBytes("OWASP roundtrip"); + PasswordKey key = PasswordKey.FromPassword("myKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Owasp2026(key)); + byte[] decrypted = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Owasp2026(key)); + decrypted.ShouldBe(plain); } [Fact] - public void Decrypt_NullInputBytes_Throws() + public void Encrypt_Decrypt_Owasp2026_ExplicitKdfIterations_RoundTrip() { - Should.Throw(() => _service.DecryptBytes(null, EncryptionAlgorithm.AES, "k", Encoding.UTF8)); - Should.Throw(() => _service.DecryptBytes(null, EncryptionAlgorithm.AES, ToSecureString("k"), Encoding.UTF8)); - Should.Throw(() => _service.DecryptBytes(null, EncryptionAlgorithm.AES, Encoding.UTF8.GetBytes("k"))); + byte[] plain = Encoding.UTF8.GetBytes("OWASP custom iters"); + PasswordKey key = PasswordKey.FromPassword("myKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Owasp2026(key, kdfIterations: 50_000)); + byte[] decrypted = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Owasp2026(key, kdfIterations: 50_000)); + decrypted.ShouldBe(plain); } [Fact] - public void Encrypt_EmptyKey_Throws() + public void Encrypt_Decrypt_OpenSslEnc_RoundTrip() { - Should.Throw(() => _service.EncryptBytes(new byte[] { 1 }, EncryptionAlgorithm.AES, string.Empty, Encoding.UTF8)); - Should.Throw(() => _service.EncryptBytes(new byte[] { 1 }, EncryptionAlgorithm.AES, Array.Empty())); + byte[] plain = Encoding.UTF8.GetBytes("openssl roundtrip"); + PasswordKey key = PasswordKey.FromPassword("myKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, EncryptionAlgorithm.AES, SymmetricEncryptOptions.OpenSslEnc(key)); + byte[] decrypted = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.OpenSslEnc(key)); + decrypted.ShouldBe(plain); } + // Classic and Owasp2026 share the same wire layout (salt ‖ IV ‖ ct, PBKDF2-HMAC-SHA1). + // Only the iter source differs — Classic is frozen at 10 000, Owasp2026 is caller-controlled. + // Encrypting with Owasp2026(10_000) must produce a blob the Classic decrypt can read, + // and vice versa — proves the dispatch routes through compatible code paths. + [Fact] + public void Encrypt_Owasp2026_10000Iter_DecryptableBy_Classic() + { + byte[] plain = Encoding.UTF8.GetBytes("cross-format wire compat"); + PasswordKey key = PasswordKey.FromPassword("crossKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Owasp2026(key, kdfIterations: 10_000)); + byte[] decrypted = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key)); + decrypted.ShouldBe(plain); + } + + [Fact] + public void Encrypt_Classic_DecryptableBy_Owasp2026_10000Iter() + { + byte[] plain = Encoding.UTF8.GetBytes("cross-format wire compat reverse"); + PasswordKey key = PasswordKey.FromPassword("crossKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key)); + byte[] decrypted = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Owasp2026(key, kdfIterations: 10_000)); + decrypted.ShouldBe(plain); + } + + // The iteration count is NOT carried in the ciphertext — encrypt and decrypt must use + // matching values. AEAD makes this deterministic: wrong key (from wrong iter) fails the + // tag check rather than silently producing garbage. Documents the cross-version-decrypt + // warning in docs/symmetric-wire-format.md. [Theory] [InlineData(EncryptionAlgorithm.AESGCM)] [InlineData(EncryptionAlgorithm.ChaCha20Poly1305)] - public void DecryptBytes_AeadShortInput_Throws(EncryptionAlgorithm algorithm) + public void Decrypt_Owasp2026_MismatchedKdfIterations_Throws(EncryptionAlgorithm algorithm) { - // Below the 36-byte AEAD floor (salt 8 + IV 12 + tag 16) the new guard surfaces - // the wire-format hint instead of an OverflowException from negative-length arithmetic. - byte[] shortInput = new byte[4]; + byte[] plain = Encoding.UTF8.GetBytes("iter mismatch"); + PasswordKey key = PasswordKey.FromPassword("myKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.Owasp2026(key, kdfIterations: 50_000)); Should.Throw( - () => _service.DecryptBytes(shortInput, algorithm, "anyKey", Encoding.UTF8)); + () => _service.DecryptBytes(cipher, algorithm, SymmetricDecryptOptions.Owasp2026(key, kdfIterations: 60_000))); } - [Fact] - public void Encrypt_NullSecureStringKey_Throws() + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.ChaCha20Poly1305)] + public void Decrypt_OpenSslEnc_MismatchedKdfIterations_Throws(EncryptionAlgorithm algorithm) { - Should.Throw(() => _service.EncryptBytes(new byte[] { 1 }, EncryptionAlgorithm.AES, (SecureString)null, Encoding.UTF8)); + byte[] plain = Encoding.UTF8.GetBytes("openssl iter mismatch"); + PasswordKey key = PasswordKey.FromPassword("myKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.OpenSslEnc(key, kdfIterations: 50_000)); + Should.Throw( + () => _service.DecryptBytes(cipher, algorithm, SymmetricDecryptOptions.OpenSslEnc(key, kdfIterations: 60_000))); } - // ═══════════════════════════════════════════════════════════════════════ - // Symmetric Encrypt / Decrypt — Text form - // ═══════════════════════════════════════════════════════════════════════ + // AEAD ciphertext carries an authentication tag covering salt + IV + ct. Flipping any + // bit must surface as a CryptographicException rather than silently returning garbage. + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.ChaCha20Poly1305)] + public void Decrypt_AeadTamperedCiphertext_Throws(EncryptionAlgorithm algorithm) + { + byte[] plain = Encoding.UTF8.GetBytes("aead tamper test — must fail"); + PasswordKey key = PasswordKey.FromPassword("aeadKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.Classic(key)); + cipher[cipher.Length / 2] ^= 0x01; // flip a bit mid-stream + Should.Throw( + () => _service.DecryptBytes(cipher, algorithm, SymmetricDecryptOptions.Classic(key))); + } + + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.ChaCha20Poly1305)] + public void Decrypt_AeadTamperedTag_Throws(EncryptionAlgorithm algorithm) + { + byte[] plain = Encoding.UTF8.GetBytes("aead tag tamper"); + PasswordKey key = PasswordKey.FromPassword("aeadKey", Encoding.UTF8); + byte[] cipher = _service.EncryptBytes(plain, algorithm, SymmetricEncryptOptions.Classic(key)); + + // Classic AEAD wire layout: salt(8) ‖ IV(12) ‖ ct ‖ tag(16). Pin the assumption + // that the last byte is part of the tag — without this, a future wire-layout tweak + // (or a degenerate short ciphertext) could silently move the flip target out of + // the tag region and the test would pass for an unrelated reason. + const int salt = 8, iv = 12, tag = 16; + cipher.Length.ShouldBeGreaterThanOrEqualTo(salt + iv + plain.Length + tag); + + cipher[^1] ^= 0x01; // flip a bit in the trailing auth tag + Should.Throw( + () => _service.DecryptBytes(cipher, algorithm, SymmetricDecryptOptions.Classic(key))); + } [Fact] - public void EncryptText_NullInput_Throws() + public void Encrypt_NullInputBytes_Throws() { - Should.Throw(() => - _service.EncryptText(null, EncryptionAlgorithm.AES, "key", Encoding.UTF8)); + PasswordKey key = PasswordKey.FromPassword("k", Encoding.UTF8); + Should.Throw(() => _service.EncryptBytes(null, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key))); } [Fact] - public void EncryptText_NullEncoding_Throws() + public void Decrypt_NullInputBytes_Throws() { - Should.Throw(() => - _service.EncryptText("input", EncryptionAlgorithm.AES, "key", null)); + PasswordKey key = PasswordKey.FromPassword("k", Encoding.UTF8); + Should.Throw(() => _service.DecryptBytes(null, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key))); } [Fact] - public void EncryptText_EmptyKey_Throws() + public void Encrypt_NullOptions_Throws() { - Should.Throw(() => - _service.EncryptText("input", EncryptionAlgorithm.AES, string.Empty, Encoding.UTF8)); + Should.Throw(() => _service.EncryptBytes(new byte[] { 1 }, EncryptionAlgorithm.AES, (SymmetricEncryptOptions)null)); } [Fact] - public void DecryptText_NullInput_Throws() + public void Decrypt_NullOptions_Throws() + { + Should.Throw(() => _service.DecryptBytes(new byte[] { 1 }, EncryptionAlgorithm.AES, (SymmetricDecryptOptions)null)); + } + + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.ChaCha20Poly1305)] + public void DecryptBytes_AeadShortInput_Throws(EncryptionAlgorithm algorithm) { - Should.Throw(() => - _service.DecryptText(null, EncryptionAlgorithm.AES, "key", Encoding.UTF8)); + byte[] shortInput = new byte[4]; + PasswordKey key = PasswordKey.FromPassword("anyKey", Encoding.UTF8); + Should.Throw( + () => _service.DecryptBytes(shortInput, algorithm, SymmetricDecryptOptions.Classic(key))); } + // Note: the runtime cases `Encrypt_RawFormat_WithPasswordKey_Throws` and + // `Encrypt_NonRawFormat_WithRawKey_Throws` from the previous design are now + // unreachable — the (key kind × wire format) pairing is enforced at compile time by + // the typed factory parameters on SymmetricEncryptOptions / SymmetricDecryptOptions + // (e.g. `Classic(PasswordKey)`, `Raw(RawKey)`). The runtime validator in + // SymmetricInteropHelper still runs as defence-in-depth but is no longer reachable + // from valid C# call sites. + [Fact] - public void DecryptText_NullEncoding_Throws() + public void Encrypt_Owasp2026_KdfIterationsBelowFloor_Throws() { - Should.Throw(() => - _service.DecryptText("aGVsbG8=", EncryptionAlgorithm.AES, "key", null)); + PasswordKey key = PasswordKey.FromPassword("k", Encoding.UTF8); + Should.Throw(() => + _service.EncryptBytes(new byte[] { 1 }, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Owasp2026(key, kdfIterations: 500))); } [Fact] - public void DecryptText_EmptyKey_Throws() + public void Encrypt_Raw_WrongKeyLength_Throws() { + RawKey shortKey = RawKey.FromBytes(new byte[7]); // not a legal AES key size Should.Throw(() => - _service.DecryptText("aGVsbG8=", EncryptionAlgorithm.AES, string.Empty, Encoding.UTF8)); + _service.EncryptBytes(new byte[] { 1 }, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Raw(shortKey))); } + // ═══════════════════════════════════════════════════════════════════════ + // Symmetric Encrypt / Decrypt — Text form + // ═══════════════════════════════════════════════════════════════════════ + [Theory] [InlineData(EncryptionAlgorithm.AES)] [InlineData(EncryptionAlgorithm.TripleDES)] - public void EncryptText_ThenDecryptText_StringKey_ReturnsOriginal(EncryptionAlgorithm algorithm) + public void EncryptText_ThenDecryptText_RoundTrip(EncryptionAlgorithm algorithm) { string original = "Hello, coded workflows!"; - string encrypted = _service.EncryptText(original, algorithm, "mySecretKey", Encoding.UTF8); - string decrypted = _service.DecryptText(encrypted, algorithm, "mySecretKey", Encoding.UTF8); + PasswordKey key = PasswordKey.FromPassword("mySecretKey", Encoding.UTF8); + string encrypted = _service.EncryptText(original, algorithm, SymmetricEncryptOptions.Classic(key)); + string decrypted = _service.DecryptText(encrypted, algorithm, SymmetricDecryptOptions.Classic(key)); decrypted.ShouldBe(original); } - [Theory] - [InlineData(EncryptionAlgorithm.AES)] - [InlineData(EncryptionAlgorithm.TripleDES)] - public void EncryptText_ThenDecryptText_SecureStringKey_ReturnsOriginal(EncryptionAlgorithm algorithm) + [Fact] + public void EncryptText_NullInput_Throws() { - string original = "Hello, SecureString!"; - SecureString key = ToSecureString("mySecretKey"); - string encrypted = _service.EncryptText(original, algorithm, key, Encoding.UTF8); - string decrypted = _service.DecryptText(encrypted, algorithm, key, Encoding.UTF8); - decrypted.ShouldBe(original); + PasswordKey key = PasswordKey.FromPassword("key", Encoding.UTF8); + Should.Throw(() => _service.EncryptText(null, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key))); } - [Theory] - [InlineData(EncryptionAlgorithm.AES)] - [InlineData(EncryptionAlgorithm.TripleDES)] - public void EncryptText_ThenDecryptText_ByteArrayKey_ReturnsOriginal(EncryptionAlgorithm algorithm) + [Fact] + public void DecryptText_NullInput_Throws() { - string original = "Hello, byte[] key!"; - byte[] keyBytes = Encoding.UTF8.GetBytes("myRawKeyBytes!!"); - string encrypted = _service.EncryptText(original, algorithm, keyBytes, Encoding.UTF8); - string decrypted = _service.DecryptText(encrypted, algorithm, keyBytes, Encoding.UTF8); - decrypted.ShouldBe(original); + PasswordKey key = PasswordKey.FromPassword("key", Encoding.UTF8); + Should.Throw(() => _service.DecryptText(null, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key))); } + // Non-UTF-8 encoding flows through the options object end-to-end. Use UTF-16LE so the + // input characters (including em-dash and Latin accents) all have a faithful representation + // and the ciphertext bytes are structurally different from the UTF-8 path. [Fact] - public void EncryptText_NullSecureStringKey_Throws() + public void EncryptText_DecryptText_NonUtf8Encoding_RoundTrip() { - Should.Throw(() => - _service.EncryptText("input", EncryptionAlgorithm.AES, (SecureString)null, Encoding.UTF8)); + string original = "café — éàü"; + PasswordKey key = PasswordKey.FromPassword("mySecretKey", Encoding.UTF8); + Encoding utf16 = Encoding.Unicode; + + string utf16Cipher = _service.EncryptText(original, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key, utf16)); + string utf16Decrypted = _service.DecryptText(utf16Cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key, utf16)); + utf16Decrypted.ShouldBe(original); + + // Decrypting UTF-16 ciphertext as UTF-8 produces garbage (or mojibake) — proving the + // option actually drives the decode side, not a hidden UTF-8 default. + string utf8Decrypted = _service.DecryptText(utf16Cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key)); + utf8Decrypted.ShouldNotBe(original); } [Fact] - public void EncryptText_NullOrEmptyByteArrayKey_Throws() + public void EncryptText_NullOptions_Throws() { - Should.Throw(() => - _service.EncryptText("input", EncryptionAlgorithm.AES, (byte[])null, Encoding.UTF8)); - Should.Throw(() => - _service.EncryptText("input", EncryptionAlgorithm.AES, Array.Empty(), Encoding.UTF8)); + Should.Throw(() => _service.EncryptText("hi", EncryptionAlgorithm.AES, options: null)); } [Fact] - public void DecryptText_NullOrEmptyByteArrayKey_Throws() + public void DecryptText_NullOptions_Throws() { - // Mirrors EncryptText_NullOrEmptyByteArrayKey_Throws — decrypt guards were missing from the suite. - Should.Throw(() => - _service.DecryptText("ZmFrZQ==", EncryptionAlgorithm.AES, (byte[])null, Encoding.UTF8)); - Should.Throw(() => - _service.DecryptText("ZmFrZQ==", EncryptionAlgorithm.AES, Array.Empty(), Encoding.UTF8)); + Should.Throw(() => _service.DecryptText("AAAA", EncryptionAlgorithm.AES, options: null)); } // ═══════════════════════════════════════════════════════════════════════ @@ -249,29 +395,17 @@ public void DecryptText_NullOrEmptyByteArrayKey_Throws() [Fact] public void EncryptFile_NullInputPath_Throws() { + PasswordKey key = PasswordKey.FromPassword("key", Encoding.UTF8); Should.Throw(() => - _service.EncryptFile(null, "out.bin", EncryptionAlgorithm.AES, "key", Encoding.UTF8, true)); - } - - [Fact] - public void EncryptFile_NullOutputPath_Throws() - { - Should.Throw(() => - _service.EncryptFile("in.txt", null, EncryptionAlgorithm.AES, "key", Encoding.UTF8, true)); + _service.EncryptFile(null, "out.bin", EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key), overwrite: true)); } [Fact] public void DecryptFile_NullInputPath_Throws() { + PasswordKey key = PasswordKey.FromPassword("key", Encoding.UTF8); Should.Throw(() => - _service.DecryptFile(null, "out.txt", EncryptionAlgorithm.AES, "key", Encoding.UTF8, true)); - } - - [Fact] - public void EncryptFile_NullOrEmptyByteArrayKey_Throws() - { - Should.Throw(() => - _service.EncryptFile("in.txt", "out.bin", EncryptionAlgorithm.AES, (byte[])null, overwrite: true)); + _service.DecryptFile(null, "out.txt", EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key), overwrite: true)); } [Fact] @@ -283,8 +417,9 @@ public void EncryptFile_ExistingOutputWithoutOverwrite_Throws() { File.WriteAllText(inputPath, "test content"); File.WriteAllText(outputPath, "existing output"); + PasswordKey key = PasswordKey.FromPassword("key", Encoding.UTF8); Should.Throw(() => - _service.EncryptFile(inputPath, outputPath, EncryptionAlgorithm.AES, "key", Encoding.UTF8, overwrite: false)); + _service.EncryptFile(inputPath, outputPath, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key), overwrite: false)); } finally { @@ -296,33 +431,18 @@ public void EncryptFile_ExistingOutputWithoutOverwrite_Throws() [Theory] [InlineData("string")] [InlineData("secure")] - [InlineData("bytes")] - public void EncryptFile_ThenDecryptFile_AllKeyForms_RoundTrip(string keyForm) + public void EncryptFile_ThenDecryptFile_RoundTrip(string keyKind) { - string original = "File round-trip — " + keyForm; + string original = "File round-trip — " + keyKind; string inputPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); string encryptedPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); string decryptedPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); try { File.WriteAllText(inputPath, original, Encoding.UTF8); - switch (keyForm) - { - case "string": - _service.EncryptFile(inputPath, encryptedPath, EncryptionAlgorithm.AES, "testKey", Encoding.UTF8, overwrite: true); - _service.DecryptFile(encryptedPath, decryptedPath, EncryptionAlgorithm.AES, "testKey", Encoding.UTF8, overwrite: true); - break; - case "secure": - var ss = ToSecureString("testKey"); - _service.EncryptFile(inputPath, encryptedPath, EncryptionAlgorithm.AES, ss, Encoding.UTF8, overwrite: true); - _service.DecryptFile(encryptedPath, decryptedPath, EncryptionAlgorithm.AES, ss, Encoding.UTF8, overwrite: true); - break; - case "bytes": - byte[] kb = Encoding.UTF8.GetBytes("testKey"); - _service.EncryptFile(inputPath, encryptedPath, EncryptionAlgorithm.AES, kb, overwrite: true); - _service.DecryptFile(encryptedPath, decryptedPath, EncryptionAlgorithm.AES, kb, overwrite: true); - break; - } + PasswordKey key = NewPasswordKey("testKey", keyKind); + _service.EncryptFile(inputPath, encryptedPath, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key), overwrite: true); + _service.DecryptFile(encryptedPath, decryptedPath, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key), overwrite: true); File.ReadAllText(decryptedPath, Encoding.UTF8).ShouldBe(original); } finally @@ -334,105 +454,66 @@ public void EncryptFile_ThenDecryptFile_AllKeyForms_RoundTrip(string keyForm) } // ═══════════════════════════════════════════════════════════════════════ - // Keyed Hash — Bytes / Text / File + // Keyed Hash — Bytes / Text / File (takes CryptoKey directly, no options) // ═══════════════════════════════════════════════════════════════════════ - [Fact] - public void KeyedHashBytes_StringKey_DeterministicAndHex() + [Theory] + [InlineData("string")] + [InlineData("secure")] + public void KeyedHashBytes_DeterministicAndHex(string keyKind) { byte[] input = Encoding.UTF8.GetBytes("hello"); - string hash1 = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, "key", Encoding.UTF8); - string hash2 = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, "key", Encoding.UTF8); + CryptoKey k1 = NewPasswordKey("key", keyKind); + CryptoKey k2 = NewPasswordKey("key", keyKind); + string hash1 = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, k1); + string hash2 = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, k2); hash1.ShouldBe(hash2); hash1.ShouldMatch("^[0-9A-F]+$"); } [Fact] - public void KeyedHashBytes_SecureStringKey_MatchesStringKey() + public void KeyedHashBytes_SecureMatchesString() { byte[] input = Encoding.UTF8.GetBytes("hello"); - string fromString = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, "k", Encoding.UTF8); - string fromSecure = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, ToSecureString("k"), Encoding.UTF8); + string fromString = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, PasswordKey.FromPassword("k", Encoding.UTF8)); + string fromSecure = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, PasswordKey.FromPassword(ToSecureString("k"), Encoding.UTF8)); fromSecure.ShouldBe(fromString); } - [Fact] - public void KeyedHashBytes_ByteArrayKey_Works() - { - byte[] input = Encoding.UTF8.GetBytes("hello"); - byte[] key = Encoding.UTF8.GetBytes("k"); - string hash = _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, key); - hash.ShouldMatch("^[0-9A-F]+$"); - } - [Fact] public void KeyedHashBytes_Guards() { - byte[] input = Encoding.UTF8.GetBytes("x"); - Should.Throw(() => _service.KeyedHashBytes(null, KeyedHashAlgorithms.HMACSHA256, "k", Encoding.UTF8)); - Should.Throw(() => _service.KeyedHashBytes(null, KeyedHashAlgorithms.HMACSHA256, ToSecureString("k"), Encoding.UTF8)); - Should.Throw(() => _service.KeyedHashBytes(null, KeyedHashAlgorithms.HMACSHA256, Encoding.UTF8.GetBytes("k"))); - Should.Throw(() => _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, string.Empty, Encoding.UTF8)); - Should.Throw(() => _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, (byte[])null)); - Should.Throw(() => _service.KeyedHashBytes(input, KeyedHashAlgorithms.HMACSHA256, (SecureString)null, Encoding.UTF8)); + CryptoKey key = PasswordKey.FromPassword("k", Encoding.UTF8); + Should.Throw(() => _service.KeyedHashBytes(null, KeyedHashAlgorithms.HMACSHA256, key)); + Should.Throw(() => _service.KeyedHashBytes(new byte[] { 1 }, KeyedHashAlgorithms.HMACSHA256, null)); } [Fact] public void KeyedHashText_Guards() { - Should.Throw(() => _service.KeyedHashText(null, KeyedHashAlgorithms.HMACSHA256, "key", Encoding.UTF8)); - Should.Throw(() => _service.KeyedHashText("input", KeyedHashAlgorithms.HMACSHA256, "key", null)); - Should.Throw(() => _service.KeyedHashText("input", KeyedHashAlgorithms.HMACSHA256, string.Empty, Encoding.UTF8)); - Should.Throw(() => _service.KeyedHashText("input", KeyedHashAlgorithms.HMACSHA256, (SecureString)null, Encoding.UTF8)); - Should.Throw(() => _service.KeyedHashText("input", KeyedHashAlgorithms.HMACSHA256, (byte[])null, Encoding.UTF8)); - } - - [Fact] - public void KeyedHashText_StringKey_DeterministicHex() - { - string h1 = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, "key", Encoding.UTF8); - string h2 = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, "key", Encoding.UTF8); - h1.ShouldBe(h2); - h1.ShouldMatch("^[0-9A-F]+$"); + CryptoKey key = PasswordKey.FromPassword("k", Encoding.UTF8); + Should.Throw(() => _service.KeyedHashText(null, KeyedHashAlgorithms.HMACSHA256, key)); + Should.Throw(() => _service.KeyedHashText("x", KeyedHashAlgorithms.HMACSHA256, null)); } [Fact] public void KeyedHashText_DifferentKeys_DifferentHash() { - string h1 = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, "k1", Encoding.UTF8); - string h2 = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, "k2", Encoding.UTF8); + string h1 = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, PasswordKey.FromPassword("k1", Encoding.UTF8)); + string h2 = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, PasswordKey.FromPassword("k2", Encoding.UTF8)); h1.ShouldNotBe(h2); } [Fact] - public void KeyedHashText_ByteArrayKey_MatchesStringKey() - { - byte[] kb = Encoding.UTF8.GetBytes("hmacKey"); - string fromBytes = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, kb, Encoding.UTF8); - string fromString = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, "hmacKey", Encoding.UTF8); - fromBytes.ShouldBe(fromString); - } - - [Theory] - [InlineData("string")] - [InlineData("secure")] - [InlineData("bytes")] - public void KeyedHashFile_AllKeyForms_RoundTrip(string keyForm) + public void KeyedHashFile_RoundTrip() { string filePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); try { - // File.WriteAllBytes (not WriteAllText) — WriteAllText with Encoding.UTF8 prepends a BOM, - // which would make the file content differ from Encoding.UTF8.GetBytes("hello") and break the equality assertion. File.WriteAllBytes(filePath, Encoding.UTF8.GetBytes("hello")); - string hashFromFile = keyForm switch - { - "string" => _service.KeyedHashFile(filePath, KeyedHashAlgorithms.HMACSHA256, "key", Encoding.UTF8), - "secure" => _service.KeyedHashFile(filePath, KeyedHashAlgorithms.HMACSHA256, ToSecureString("key"), Encoding.UTF8), - "bytes" => _service.KeyedHashFile(filePath, KeyedHashAlgorithms.HMACSHA256, Encoding.UTF8.GetBytes("key")), - _ => throw new InvalidOperationException(), - }; - string hashFromText = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, "key", Encoding.UTF8); + CryptoKey key = PasswordKey.FromPassword("key", Encoding.UTF8); + string hashFromFile = _service.KeyedHashFile(filePath, KeyedHashAlgorithms.HMACSHA256, key); + string hashFromText = _service.KeyedHashText("hello", KeyedHashAlgorithms.HMACSHA256, key); hashFromFile.ShouldBe(hashFromText); } finally @@ -444,63 +525,45 @@ public void KeyedHashFile_AllKeyForms_RoundTrip(string keyForm) [Fact] public void KeyedHashFile_Guards() { - Should.Throw(() => _service.KeyedHashFile(null, KeyedHashAlgorithms.HMACSHA256, "key", Encoding.UTF8)); - Should.Throw(() => _service.KeyedHashFile("file.txt", KeyedHashAlgorithms.HMACSHA256, (byte[])null)); + CryptoKey key = PasswordKey.FromPassword("k", Encoding.UTF8); + Should.Throw(() => _service.KeyedHashFile(null, KeyedHashAlgorithms.HMACSHA256, key)); + Should.Throw(() => _service.KeyedHashFile("file.txt", KeyedHashAlgorithms.HMACSHA256, null)); } // ═══════════════════════════════════════════════════════════════════════ // PGP Encrypt / Decrypt — Bytes / Text / File // ═══════════════════════════════════════════════════════════════════════ - [Theory] - [InlineData(false)] // string passphrase - [InlineData(true)] // SecureString passphrase - public void PgpEncrypt_Decrypt_Bytes_RoundTrip(bool useSecure) + [Fact] + public void PgpEncrypt_Decrypt_Bytes_RoundTrip() { byte[] plain = Encoding.UTF8.GetBytes("PGP bytes round-trip"); - byte[] cipher; - byte[] decrypted; - if (useSecure) - { - var ss = ToSecureString(PgpKeyFixture.Passphrase); - cipher = _service.PgpEncryptBytes(plain, _keys.PublicKey, _keys.PrivateKey, ss); - decrypted = _service.PgpDecryptBytes(cipher, _keys.PrivateKey, ss); - } - else - { - cipher = _service.PgpEncryptBytes(plain, _keys.PublicKey); - decrypted = _service.PgpDecryptBytes(cipher, _keys.PrivateKey, PgpKeyFixture.Passphrase); - } + byte[] cipher = _service.PgpEncryptBytes(plain, _keys.PublicKey); + byte[] decrypted = _service.PgpDecryptBytes(cipher, _keys.PrivateKey); decrypted.ShouldBe(plain); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void PgpEncrypt_Decrypt_Text_RoundTrip(bool useSecure) + [Fact] + public void PgpEncrypt_Decrypt_Bytes_SignedAndVerified_RoundTrip() + { + byte[] plain = Encoding.UTF8.GetBytes("Signed PGP roundtrip"); + byte[] cipher = _service.PgpEncryptBytes(plain, _keys.PublicKey, signer: _keys.PrivateKey); + byte[] decrypted = _service.PgpDecryptBytes(cipher, _keys.PrivateKey, verifier: _keys.PublicKey); + decrypted.ShouldBe(plain); + } + + [Fact] + public void PgpEncrypt_Decrypt_Text_RoundTrip() { const string plain = "PGP text round-trip"; - string cipher; - string decrypted; - if (useSecure) - { - var ss = ToSecureString(PgpKeyFixture.Passphrase); - cipher = _service.PgpEncryptText(plain, _keys.PublicKey, _keys.PrivateKey, ss); - decrypted = _service.PgpDecryptText(cipher, _keys.PrivateKey, ss); - } - else - { - cipher = _service.PgpEncryptText(plain, _keys.PublicKey); - decrypted = _service.PgpDecryptText(cipher, _keys.PrivateKey, PgpKeyFixture.Passphrase); - } + string cipher = _service.PgpEncryptText(plain, _keys.PublicKey); cipher.ShouldStartWith("-----BEGIN PGP MESSAGE-----"); + string decrypted = _service.PgpDecryptText(cipher, _keys.PrivateKey); decrypted.ShouldBe(plain); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void PgpEncryptFile_DecryptFile_RoundTrip(bool useSecure) + [Fact] + public void PgpEncryptFile_DecryptFile_RoundTrip() { const string original = "PGP file round-trip"; string inputPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); @@ -509,17 +572,8 @@ public void PgpEncryptFile_DecryptFile_RoundTrip(bool useSecure) try { File.WriteAllText(inputPath, original, Encoding.UTF8); - if (useSecure) - { - var ss = ToSecureString(PgpKeyFixture.Passphrase); - _service.PgpEncryptFile(inputPath, encryptedPath, _keys.PublicKey, null, ss, sign: false, overwrite: true); - _service.PgpDecryptFile(encryptedPath, decryptedPath, _keys.PrivateKey, ss, null, verifySignature: false, overwrite: true); - } - else - { - _service.PgpEncryptFile(inputPath, encryptedPath, _keys.PublicKey, null, (string)null, sign: false, overwrite: true); - _service.PgpDecryptFile(encryptedPath, decryptedPath, _keys.PrivateKey, PgpKeyFixture.Passphrase, null, verifySignature: false, overwrite: true); - } + _service.PgpEncryptFile(inputPath, encryptedPath, _keys.PublicKey, overwrite: true); + _service.PgpDecryptFile(encryptedPath, decryptedPath, _keys.PrivateKey, overwrite: true); File.ReadAllText(decryptedPath, Encoding.UTF8).ShouldBe(original); } finally @@ -533,73 +587,54 @@ public void PgpEncryptFile_DecryptFile_RoundTrip(bool useSecure) [Fact] public void PgpEncrypt_Guards() { - Should.Throw(() => _service.PgpEncryptBytes(null, Array.Empty())); - Should.Throw(() => _service.PgpEncryptBytes(new byte[] { 1 }, (byte[])null)); - Should.Throw(() => _service.PgpEncryptBytes(new byte[] { 1 }, Array.Empty(), Array.Empty(), (SecureString)null)); + Should.Throw(() => _service.PgpEncryptBytes(null, _keys.PublicKey)); + Should.Throw(() => _service.PgpEncryptBytes(new byte[] { 1 }, null)); } [Fact] public void PgpDecrypt_Guards() { - Should.Throw(() => _service.PgpDecryptBytes(null, Array.Empty(), "pass")); - Should.Throw(() => _service.PgpDecryptBytes(new byte[] { 1 }, (byte[])null, "pass")); - Should.Throw(() => _service.PgpDecryptBytes(new byte[] { 1 }, Array.Empty(), (SecureString)null)); + Should.Throw(() => _service.PgpDecryptBytes(null, _keys.PrivateKey)); + Should.Throw(() => _service.PgpDecryptBytes(new byte[] { 1 }, null)); } [Fact] public void PgpEncryptText_DecryptText_NullInput_Throws() { - Should.Throw(() => _service.PgpEncryptText(null, Array.Empty())); - Should.Throw(() => _service.PgpDecryptText(null, Array.Empty(), "pass")); + Should.Throw(() => _service.PgpEncryptText(null, _keys.PublicKey)); + Should.Throw(() => _service.PgpDecryptText(null, _keys.PrivateKey)); } // ═══════════════════════════════════════════════════════════════════════ - // PGP Sign / Clearsign + Verify — Bytes / Text / File + // PGP Sign / ClearSign + Verify — Bytes / Text / File // ═══════════════════════════════════════════════════════════════════════ - [Theory] - [InlineData(false)] - [InlineData(true)] - public void PgpSign_Bytes_Then_PgpVerify_RoundTrip(bool useSecure) + [Fact] + public void PgpSign_Bytes_Then_PgpVerify_RoundTrip() { byte[] plain = Encoding.UTF8.GetBytes("Sign me"); - byte[] signed = useSecure - ? _service.PgpSignBytes(plain, _keys.PrivateKey, ToSecureString(PgpKeyFixture.Passphrase)) - : _service.PgpSignBytes(plain, _keys.PrivateKey, PgpKeyFixture.Passphrase); + byte[] signed = _service.PgpSignBytes(plain, _keys.PrivateKey); _service.PgpVerifyBytes(signed, _keys.PublicKey).ShouldBeTrue(); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void PgpSignText_Then_PgpVerifyText_RoundTrip(bool useSecure) + [Fact] + public void PgpSignText_Then_PgpVerifyText_RoundTrip() { const string plain = "Sign-me-text"; - string signed = useSecure - ? _service.PgpSignText(plain, _keys.PrivateKey, ToSecureString(PgpKeyFixture.Passphrase)) - : _service.PgpSignText(plain, _keys.PrivateKey, PgpKeyFixture.Passphrase); + string signed = _service.PgpSignText(plain, _keys.PrivateKey); signed.ShouldStartWith("-----BEGIN PGP MESSAGE-----"); _service.PgpVerifyText(signed, _keys.PublicKey).ShouldBeTrue(); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void PgpSignFile_Then_PgpVerifyFile_RoundTrip(bool useSecure) + [Fact] + public void PgpSignFile_Then_PgpVerifyFile_RoundTrip() { string inputPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); string signedPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); try { File.WriteAllText(inputPath, "Sign-me-file", Encoding.UTF8); - if (useSecure) - { - _service.PgpSignFile(inputPath, signedPath, _keys.PrivateKey, ToSecureString(PgpKeyFixture.Passphrase), overwrite: true); - } - else - { - _service.PgpSignFile(inputPath, signedPath, _keys.PrivateKey, PgpKeyFixture.Passphrase, overwrite: true); - } + _service.PgpSignFile(inputPath, signedPath, _keys.PrivateKey, overwrite: true); _service.PgpVerifyFile(signedPath, _keys.PublicKey).ShouldBeTrue(); } finally @@ -609,50 +644,33 @@ public void PgpSignFile_Then_PgpVerifyFile_RoundTrip(bool useSecure) } } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void PgpClearsign_Bytes_Then_PgpVerifyClear_RoundTrip(bool useSecure) + [Fact] + public void PgpClearSign_Bytes_Then_PgpVerifyClearSigned_RoundTrip() { - byte[] plain = Encoding.UTF8.GetBytes("Clearsign me"); - byte[] signed = useSecure - ? _service.PgpClearsignBytes(plain, _keys.PrivateKey, ToSecureString(PgpKeyFixture.Passphrase)) - : _service.PgpClearsignBytes(plain, _keys.PrivateKey, PgpKeyFixture.Passphrase); - _service.PgpVerifyClearBytes(signed, _keys.PublicKey).ShouldBeTrue(); + byte[] plain = Encoding.UTF8.GetBytes("ClearSign me"); + byte[] signed = _service.PgpClearSignBytes(plain, _keys.PrivateKey); + _service.PgpVerifyClearSignedBytes(signed, _keys.PublicKey).ShouldBeTrue(); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void PgpClearsignText_Then_PgpVerifyClearText_RoundTrip(bool useSecure) - { - const string plain = "Clearsign-me-text"; - string signed = useSecure - ? _service.PgpClearsignText(plain, _keys.PrivateKey, ToSecureString(PgpKeyFixture.Passphrase)) - : _service.PgpClearsignText(plain, _keys.PrivateKey, PgpKeyFixture.Passphrase); + [Fact] + public void PgpClearSignText_Then_PgpVerifyClearSignedText_RoundTrip() + { + const string plain = "ClearSign-me-text"; + string signed = _service.PgpClearSignText(plain, _keys.PrivateKey); signed.ShouldStartWith("-----BEGIN PGP SIGNED MESSAGE-----"); - _service.PgpVerifyClearText(signed, _keys.PublicKey).ShouldBeTrue(); + _service.PgpVerifyClearSignedText(signed, _keys.PublicKey).ShouldBeTrue(); } - [Theory] - [InlineData(false)] - [InlineData(true)] - public void PgpClearsignFile_Then_PgpVerifyClearFile_RoundTrip(bool useSecure) + [Fact] + public void PgpClearSignFile_Then_PgpVerifyClearSignedFile_RoundTrip() { string inputPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); string signedPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); try { - File.WriteAllText(inputPath, "Clearsign-me-file", Encoding.UTF8); - if (useSecure) - { - _service.PgpClearsignFile(inputPath, signedPath, _keys.PrivateKey, ToSecureString(PgpKeyFixture.Passphrase), overwrite: true); - } - else - { - _service.PgpClearsignFile(inputPath, signedPath, _keys.PrivateKey, PgpKeyFixture.Passphrase, overwrite: true); - } - _service.PgpVerifyClearFile(signedPath, _keys.PublicKey).ShouldBeTrue(); + File.WriteAllText(inputPath, "ClearSign-me-file", Encoding.UTF8); + _service.PgpClearSignFile(inputPath, signedPath, _keys.PrivateKey, overwrite: true); + _service.PgpVerifyClearSignedFile(signedPath, _keys.PublicKey).ShouldBeTrue(); } finally { @@ -662,10 +680,12 @@ public void PgpClearsignFile_Then_PgpVerifyClearFile_RoundTrip(bool useSecure) } [Fact] - public void PgpSign_Clearsign_Guards() + public void PgpSign_ClearSign_Guards() { - Should.Throw(() => _service.PgpSignBytes(null, Array.Empty(), "pass")); - Should.Throw(() => _service.PgpClearsignBytes(null, Array.Empty(), "pass")); + Should.Throw(() => _service.PgpSignBytes(null, _keys.PrivateKey)); + Should.Throw(() => _service.PgpClearSignBytes(null, _keys.PrivateKey)); + Should.Throw(() => _service.PgpSignBytes(new byte[] { 1 }, null)); + Should.Throw(() => _service.PgpClearSignBytes(new byte[] { 1 }, null)); } // ═══════════════════════════════════════════════════════════════════════ @@ -675,129 +695,154 @@ public void PgpSign_Clearsign_Guards() [Fact] public void PgpVerify_Guards() { - Should.Throw(() => _service.PgpVerifyBytes(null, Array.Empty())); - Should.Throw(() => _service.PgpVerifyClearBytes(null, Array.Empty())); - Should.Throw(() => _service.PgpVerifyText(null, Array.Empty())); - Should.Throw(() => _service.PgpVerifyClearText(null, Array.Empty())); + Should.Throw(() => _service.PgpVerifyBytes(null, _keys.PublicKey)); + Should.Throw(() => _service.PgpVerifyClearSignedBytes(null, _keys.PublicKey)); + Should.Throw(() => _service.PgpVerifyText(null, _keys.PublicKey)); + Should.Throw(() => _service.PgpVerifyClearSignedText(null, _keys.PublicKey)); Should.Throw(() => _service.PgpVerifyFile(null, _keys.PublicKey)); - Should.Throw(() => _service.PgpVerifyClearFile(null, _keys.PublicKey)); + Should.Throw(() => _service.PgpVerifyClearSignedFile(null, _keys.PublicKey)); } [Fact] public void PgpVerify_TamperedBytes_ReturnsFalse() { byte[] plain = Encoding.UTF8.GetBytes("tamper test"); - byte[] signed = _service.PgpSignBytes(plain, _keys.PrivateKey, PgpKeyFixture.Passphrase); - signed[signed.Length / 2] ^= 0x01; // flip a bit + byte[] signed = _service.PgpSignBytes(plain, _keys.PrivateKey); + signed[signed.Length / 2] ^= 0x01; _service.PgpVerifyBytes(signed, _keys.PublicKey).ShouldBeFalse(); } // ═══════════════════════════════════════════════════════════════════════ - // PGP VerifyPublicKey — Bytes / Text / File + // PGP VerifyPublicKey // ═══════════════════════════════════════════════════════════════════════ [Fact] - public void PgpVerifyPublicKey_Bytes_ValidKey_ReturnsTrue() + public void PgpVerifyPublicKey_FromFixture_ReturnsTrue() { - _service.PgpVerifyPublicKeyBytes(_keys.PublicKey).ShouldBeTrue(); + _service.PgpVerifyPublicKey(_keys.PublicKey).ShouldBeTrue(); } [Fact] - public void PgpVerifyPublicKey_Text_ValidKey_ReturnsTrue() + public void PgpVerifyPublicKey_LoadedFromFile_ReturnsTrue() { - _service.PgpVerifyPublicKeyText(_keys.PublicKeyArmored).ShouldBeTrue(); + PgpPublicKey loaded = PgpPublicKey.FromFilePath(_keys.PublicKeyPath); + _service.PgpVerifyPublicKey(loaded).ShouldBeTrue(); } [Fact] - public void PgpVerifyPublicKey_File_ValidKey_ReturnsTrue() + public void PgpVerifyPublicKey_GarbageBytes_ReturnsFalse() { - _service.PgpVerifyPublicKeyFile(_keys.PublicKeyPath).ShouldBeTrue(); + PgpPublicKey junk = PgpPublicKey.FromBytes(Encoding.UTF8.GetBytes("not a key")); + _service.PgpVerifyPublicKey(junk).ShouldBeFalse(); } [Fact] - public void PgpVerifyPublicKey_GarbageBytes_ReturnsFalse() + public void PgpVerifyPublicKey_NullKey_Throws() { - _service.PgpVerifyPublicKeyBytes(Encoding.UTF8.GetBytes("not a key")).ShouldBeFalse(); + Should.Throw(() => _service.PgpVerifyPublicKey(null)); } + // ═══════════════════════════════════════════════════════════════════════ + // PGP Generate Keys + // ═══════════════════════════════════════════════════════════════════════ + [Fact] - public void PgpVerifyPublicKey_GarbageText_ReturnsFalse() + public void PgpGenerateKeys_Guards() { - _service.PgpVerifyPublicKeyText("not a key").ShouldBeFalse(); + Should.Throw(() => _service.PgpGenerateKeys(null, "pass")); + Should.Throw(() => _service.PgpGenerateKeys(string.Empty, "pass")); + Should.Throw(() => _service.PgpGenerateKeys("user", (SecureString)null)); } [Fact] - public void PgpVerifyPublicKey_Guards() + public void PgpGenerateKeys_StringPassphrase_ProducesUsableKeyPair() { - Should.Throw(() => _service.PgpVerifyPublicKeyBytes((byte[])null)); - Should.Throw(() => _service.PgpVerifyPublicKeyText(null)); - Should.Throw(() => _service.PgpVerifyPublicKeyText(string.Empty)); - Should.Throw(() => _service.PgpVerifyPublicKeyFile(null)); + PgpKeyPair pair = _service.PgpGenerateKeys("Gen Test ", "gen-pass", RsaKeySize.Rsa2048); + _service.PgpVerifyPublicKey(pair.PublicKey).ShouldBeTrue(); + byte[] cipher = _service.PgpEncryptBytes(Encoding.UTF8.GetBytes("ok"), pair.PublicKey); + byte[] plain = _service.PgpDecryptBytes(cipher, pair.PrivateKey); + Encoding.UTF8.GetString(plain).ShouldBe("ok"); + } + + [Fact] + public void PgpGenerateKeys_SecureStringPassphrase_ProducesUsableKeyPair() + { + PgpKeyPair pair = _service.PgpGenerateKeys("Sec Gen ", ToSecureString("sec-pass"), RsaKeySize.Rsa2048); + _service.PgpVerifyPublicKey(pair.PublicKey).ShouldBeTrue(); + byte[] cipher = _service.PgpEncryptBytes(Encoding.UTF8.GetBytes("sec"), pair.PublicKey); + byte[] plain = _service.PgpDecryptBytes(cipher, pair.PrivateKey); + Encoding.UTF8.GetString(plain).ShouldBe("sec"); + } + + [Fact] + public void PgpGenerateKeys_Deconstruct_YieldsBothHalves() + { + PgpKeyPair pair = _service.PgpGenerateKeys("Decon ", "decon-pass", RsaKeySize.Rsa2048); + (PgpPublicKey pub, PgpPrivateKey priv) = pair; + pub.ShouldNotBeNull(); + priv.ShouldNotBeNull(); } // ═══════════════════════════════════════════════════════════════════════ - // PGP Generate Keys — 4-arg + 5-arg roundtrips + // Model-type guards // ═══════════════════════════════════════════════════════════════════════ [Fact] - public void PgpGenerateKeys_Guards() + public void PasswordKey_From_GuardsEmptyOrNull() { - Should.Throw(() => _service.PgpGenerateKeys(null, "private.asc", "user", "pass")); - Should.Throw(() => _service.PgpGenerateKeys("public.asc", null, "user", "pass")); - Should.Throw(() => _service.PgpGenerateKeys(null, "private.asc", "user", "pass", RsaKeySize.Rsa2048)); + Should.Throw(() => PasswordKey.FromPassword((string)null, Encoding.UTF8)); + Should.Throw(() => PasswordKey.FromPassword(string.Empty, Encoding.UTF8)); + Should.Throw(() => PasswordKey.FromPassword("k", null)); + Should.Throw(() => PasswordKey.FromPassword((SecureString)null, Encoding.UTF8)); } [Fact] - public void PgpGenerateKeys_4Arg_ProducesUsableKeyPair() + public void RawKey_FromBytes_GuardsEmptyOrNull() { - string pubPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - string privPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - try - { - _service.PgpGenerateKeys(pubPath, privPath, "Gen Test ", "gen-pass"); - byte[] pub = File.ReadAllBytes(pubPath); - byte[] priv = File.ReadAllBytes(privPath); - _service.PgpVerifyPublicKeyBytes(pub).ShouldBeTrue(); - byte[] cipher = _service.PgpEncryptBytes(Encoding.UTF8.GetBytes("ok"), pub); - byte[] plain = _service.PgpDecryptBytes(cipher, priv, "gen-pass"); - Encoding.UTF8.GetString(plain).ShouldBe("ok"); - } - finally - { - if (File.Exists(pubPath)) File.Delete(pubPath); - if (File.Exists(privPath)) File.Delete(privPath); - } + Should.Throw(() => RawKey.FromBytes(null)); + Should.Throw(() => RawKey.FromBytes(Array.Empty())); } - [Theory] - [InlineData(RsaKeySize.Rsa2048)] - [InlineData(RsaKeySize.Rsa3072)] - public void PgpGenerateKeys_5Arg_RespectsKeySize(RsaKeySize size) + [Fact] + public void RawKey_FromHex_GuardsEmptyOrNull() { - string pubPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - string privPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - try - { - _service.PgpGenerateKeys(pubPath, privPath, "Sized ", "sized-pass", size); - _service.PgpVerifyPublicKeyFile(pubPath).ShouldBeTrue(); - // Cross-check by completing an encrypt/decrypt round-trip. - byte[] pub = File.ReadAllBytes(pubPath); - byte[] priv = File.ReadAllBytes(privPath); - byte[] cipher = _service.PgpEncryptBytes(Encoding.UTF8.GetBytes("rt"), pub); - byte[] plain = _service.PgpDecryptBytes(cipher, priv, "sized-pass"); - Encoding.UTF8.GetString(plain).ShouldBe("rt"); - } - finally - { - if (File.Exists(pubPath)) File.Delete(pubPath); - if (File.Exists(privPath)) File.Delete(privPath); - } + Should.Throw(() => RawKey.FromHex(null)); + Should.Throw(() => RawKey.FromHex(string.Empty)); + } + + [Fact] + public void RawKey_FromBase64_GuardsEmptyOrNull() + { + Should.Throw(() => RawKey.FromBase64(null)); + Should.Throw(() => RawKey.FromBase64(string.Empty)); + } + + [Fact] + public void PgpPublicKey_FromBytes_GuardsEmptyOrNull() + { + Should.Throw(() => PgpPublicKey.FromBytes(null)); + Should.Throw(() => PgpPublicKey.FromBytes(Array.Empty())); + } + + [Fact] + public void PgpPrivateKey_FromBytes_GuardsEmptyOrNull() + { + Should.Throw(() => PgpPrivateKey.FromBytes(null, "pass")); + Should.Throw(() => PgpPrivateKey.FromBytes(Array.Empty(), "pass")); + Should.Throw(() => PgpPrivateKey.FromBytes(new byte[] { 1 }, (SecureString)null)); } // ─────────────────────────────────────────────────────────────────────── // helpers // ─────────────────────────────────────────────────────────────────────── + private static PasswordKey NewPasswordKey(string value, string keyKind) => keyKind switch + { + "string" => PasswordKey.FromPassword(value, Encoding.UTF8), + "secure" => PasswordKey.FromPassword(ToSecureString(value), Encoding.UTF8), + _ => throw new ArgumentOutOfRangeException(nameof(keyKind)), + }; + private static SecureString ToSecureString(string value) { var ss = new SecureString(); @@ -806,6 +851,16 @@ private static SecureString ToSecureString(string value) ss.MakeReadOnly(); return ss; } + + // Build a deterministic test key buffer without invoking a (CA5394-flagged) RNG — + // tests need stable bytes, not actual randomness. The offset parameter lets different + // test cases pick different byte sequences so they don't collide on a single key. + private static byte[] MakeDeterministicKey(int sizeBytes, int offset) + { + byte[] bytes = new byte[sizeBytes]; + for (int i = 0; i < sizeBytes; i++) bytes[i] = (byte)(offset + i); + return bytes; + } } #pragma warning restore CS0618 } diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/ExternalInteropGpgCliTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/ExternalInteropGpgCliTests.cs new file mode 100644 index 000000000..b07f51399 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/ExternalInteropGpgCliTests.cs @@ -0,0 +1,416 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Activities.API; +using UiPath.Cryptography.Enums; +using Xunit; + +namespace UiPath.Cryptography.Activities.API.Tests +{ + /// + /// Per-test-class fixture that builds an isolated --homedir, imports the + /// shared PGP keypair, and tears down on dispose. Each test runs gpg with this + /// homedir so the developer's real keyring is untouched. + /// + public sealed class GpgIsolatedHomedir : IDisposable + { + private const string PublicKeyFilename = "ext_pub.asc"; + private const string PrivateKeyFilename = "ext_priv.asc"; + + public PgpKeyFixture Keys { get; } + public string HomeDir { get; } + public string GpgFingerprint { get; private set; } + public bool PublicKeyImported { get; private set; } + public bool SecretKeyImported { get; private set; } + public string LastImportError { get; private set; } + +#pragma warning disable CA1031 // Fixture: per-step broad catches let imports degrade gracefully (LastImportError records the failure and dependent tests no-op). Any specific exception type from gpg/IO is treated the same way. + public GpgIsolatedHomedir() + { + Keys = new PgpKeyFixture(); + HomeDir = Path.Combine(Path.GetTempPath(), $"gpg_homedir_{Guid.NewGuid():N}"); + Directory.CreateDirectory(HomeDir); + + if (!GpgCli.Probe()) return; + + // Import public key, then verify via --list-keys. The exit code of `gpg --import` + // is unreliable on some Windows MSYS builds — it can be 2 because of a gpg-agent + // hiccup even though the key WAS added to the keyring. So we run import-and-swallow, + // then list-keys to confirm and grab the fingerprint. + string pubPath = Path.Combine(HomeDir, PublicKeyFilename); + File.WriteAllBytes(pubPath, Keys.PublicKeyBytes); + try { GpgCli.Run(HomeDir, $"--batch --import \"{GpgCli.NormalisePath(pubPath)}\""); } + catch (Exception ex) { LastImportError = ex.Message; } + + try + { + GpgFingerprint = ExtractFirstFingerprint( + GpgCli.RunCapture(HomeDir, "--with-colons --list-keys")); + PublicKeyImported = !string.IsNullOrEmpty(GpgFingerprint); + } + catch (Exception ex) + { + LastImportError = (LastImportError + " | " + ex.Message).Trim('|', ' '); + } + + if (!PublicKeyImported) return; + + // Same pattern for the private key — import then verify via --list-secret-keys. + string privPath = Path.Combine(HomeDir, PrivateKeyFilename); + File.WriteAllBytes(privPath, Keys.PrivateKeyBytes); + try { GpgCli.Run(HomeDir, $"--batch --pinentry-mode loopback --passphrase {PgpKeyFixture.Passphrase} --import \"{GpgCli.NormalisePath(privPath)}\""); } + catch (Exception ex) { LastImportError = (LastImportError + " | " + ex.Message).Trim('|', ' '); } + + try + { + string secrets = GpgCli.RunCapture(HomeDir, "--with-colons --list-secret-keys"); + SecretKeyImported = secrets.Contains("sec:", StringComparison.Ordinal); + } + catch { SecretKeyImported = false; } + } +#pragma warning restore CA1031 + +#pragma warning disable CA1031 // Allow broad catch — IDisposable.Dispose must never throw; cleanup is best-effort. + public void Dispose() + { + try { Keys.Dispose(); } catch { } + try { if (Directory.Exists(HomeDir)) Directory.Delete(HomeDir, recursive: true); } catch { } + } +#pragma warning restore CA1031 + + private static string ExtractFirstFingerprint(string colons) + { + // gpg --with-colons emits records like "fpr:::::::::ABCDEF1234567890:" — the 10th colon-delimited field is the fingerprint. + foreach (string line in colons.Split('\n')) + { + if (!line.StartsWith("fpr:", StringComparison.Ordinal)) continue; + string[] parts = line.Split(':'); + if (parts.Length >= 10 && !string.IsNullOrEmpty(parts[9])) return parts[9]; + } + throw new InvalidOperationException("Could not extract fingerprint from gpg --list-keys output:\n" + colons); + } + } + + /// + /// Live bidirectional interop tests against the GnuPG CLI. Skip if gpg is + /// not on PATH. On agents that have GnuPG these tests prove our PGP wire format + /// against the canonical reference implementation. + /// + [Trait("Category", "Interop-CLI")] + public class ExternalInteropGpgCliTests : IClassFixture + { + private const string Plaintext = "GPG CLI interop. UTF-8 ăîș 0123"; + private readonly CryptographyService _service = new CryptographyService(); + private readonly GpgIsolatedHomedir _gpg; + + public ExternalInteropGpgCliTests(GpgIsolatedHomedir gpg) => _gpg = gpg; + + // Visible diagnostic: surface why GPG tests no-op on this machine. Failing this test + // when neither half imported flags a setup problem worth investigating (rather than + // silently skipping all the real tests). + [Fact] + public void Fixture_AtLeastPublicKeyImported_OrSetupNotApplicable() + { + // No GPG on PATH → not a setup problem, nothing to do. + if (!GpgCli.Probe()) return; + + // GPG present but neither key made it onto the keyring → surface the diagnostic. + if (!_gpg.PublicKeyImported && !_gpg.SecretKeyImported) + { + Assert.Fail($"gpg is on PATH but no keys were imported. Last error: {_gpg.LastImportError ?? "(none)"}"); + } + } + + // ──────────────────────────────────────────────────────────────────────── + // UiPath encrypts → gpg --decrypt verifies plaintext + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void UiPathEncrypts_GpgDecrypts_RoundTrips() + { + if (!_gpg.SecretKeyImported) return; // gpg side decrypts — needs private key on keyring + + byte[] cipherBytes = _service.PgpEncryptBytes(Encoding.UTF8.GetBytes(Plaintext), _gpg.Keys.PublicKey); + + string cipherPath = NewTempPath(); + string outPath = NewTempPath(); + try + { + File.WriteAllBytes(cipherPath, cipherBytes); + + GpgCli.Run(_gpg.HomeDir, + "--batch --pinentry-mode loopback", + $"--passphrase {PgpKeyFixture.Passphrase}", + "--decrypt", + $"--output \"{GpgCli.NormalisePath(outPath)}\"", + $"\"{GpgCli.NormalisePath(cipherPath)}\""); + + Encoding.UTF8.GetString(File.ReadAllBytes(outPath)).ShouldBe(Plaintext); + } + finally { Cleanup(cipherPath, outPath); } + } + + // ──────────────────────────────────────────────────────────────────────── + // gpg --encrypt → UiPath PgpDecryptBytes recovers plaintext + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void GpgEncrypts_UiPathDecrypts_RoundTrips() + { + if (!_gpg.PublicKeyImported) return; // gpg side encrypts to the imported public key + + string plainPath = NewTempPath(); + string cipherPath = NewTempPath(); + try + { + File.WriteAllBytes(plainPath, Encoding.UTF8.GetBytes(Plaintext)); + + GpgCli.Run(_gpg.HomeDir, + "--batch --yes", + "--trust-model always", + $"--recipient {_gpg.GpgFingerprint}", + "--encrypt", + $"--output \"{GpgCli.NormalisePath(cipherPath)}\"", + $"\"{GpgCli.NormalisePath(plainPath)}\""); + + byte[] cipher = File.ReadAllBytes(cipherPath); + byte[] plain = _service.PgpDecryptBytes(cipher, _gpg.Keys.PrivateKey); + Encoding.UTF8.GetString(plain).ShouldBe(Plaintext); + } + finally { Cleanup(plainPath, cipherPath); } + } + + // ──────────────────────────────────────────────────────────────────────── + // UiPath signs → gpg --verify confirms the signature + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void UiPathSigns_GpgVerifies() + { + if (!_gpg.PublicKeyImported) return; // gpg verify only needs the public key on keyring + + byte[] signedBytes = _service.PgpSignBytes(Encoding.UTF8.GetBytes(Plaintext), _gpg.Keys.PrivateKey); + + string signedPath = NewTempPath(); + try + { + File.WriteAllBytes(signedPath, signedBytes); + + // gpg --verify exits 0 on a valid signature, non-zero on bad/missing. + // GpgCli.Run throws on non-zero; the explicit Should.NotThrow makes the + // assertion visible to readers and stays correct if Run's error-handling + // contract ever changes (e.g. to log-and-continue). + Should.NotThrow(() => + GpgCli.Run(_gpg.HomeDir, + "--batch --pinentry-mode loopback", + $"--passphrase {PgpKeyFixture.Passphrase}", + "--verify", + $"\"{GpgCli.NormalisePath(signedPath)}\"")); + + // Negative side of the same assertion: tamper the signed bytes and confirm + // gpg rejects them. Without this, a future GpgCli.Run that silently + // swallowed non-zero exits would still pass the positive test above. + byte[] tampered = (byte[])signedBytes.Clone(); + tampered[tampered.Length / 2] ^= 0x01; + string tamperedPath = NewTempPath(); + try + { + File.WriteAllBytes(tamperedPath, tampered); + Should.Throw(() => + GpgCli.Run(_gpg.HomeDir, + "--batch --pinentry-mode loopback", + $"--passphrase {PgpKeyFixture.Passphrase}", + "--verify", + $"\"{GpgCli.NormalisePath(tamperedPath)}\"")); + } + finally { Cleanup(tamperedPath); } + } + finally { Cleanup(signedPath); } + } + + // ──────────────────────────────────────────────────────────────────────── + // UiPath clear-signs → gpg --verify on the cleartext signature + // (Writes the armored text WITHOUT a UTF-8 BOM — gpg can't parse the + // "-----BEGIN PGP SIGNED MESSAGE-----" header line if it starts with 0xEF 0xBB 0xBF.) + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void UiPathClearSigns_GpgVerifies() + { + if (!_gpg.PublicKeyImported) return; + + string clearSigned = _service.PgpClearSignText(Plaintext, _gpg.Keys.PrivateKey); + + string signedPath = NewTempPath(); + try + { + // CRITICAL: write without UTF-8 BOM. The default `Encoding.UTF8` instance + // emits a BOM (0xEF 0xBB 0xBF) at the start of the file, which makes gpg + // fail to recognise the "-----BEGIN PGP SIGNED MESSAGE-----" header line + // ("gpg: no signed data" / "can't hash datafile: No data"). + File.WriteAllText(signedPath, clearSigned, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + + // gpg --verify exits 0 on a valid cleartext signature; GpgCli.Run throws on + // non-zero. Should.NotThrow makes the assertion explicit. + Should.NotThrow(() => + GpgCli.Run(_gpg.HomeDir, + "--batch --pinentry-mode loopback", + $"--passphrase {PgpKeyFixture.Passphrase}", + "--verify", + $"\"{GpgCli.NormalisePath(signedPath)}\"")); + + // Tamper the plaintext line of the clearsigned message — the signature + // covers it, so gpg must reject. Without this negative case, a future + // GpgCli.Run that swallowed errors would still pass the positive test. + string tampered = clearSigned.Replace(Plaintext, Plaintext + "x", StringComparison.Ordinal); + string tamperedPath = NewTempPath(); + try + { + File.WriteAllText(tamperedPath, tampered, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + Should.Throw(() => + GpgCli.Run(_gpg.HomeDir, + "--batch --pinentry-mode loopback", + $"--passphrase {PgpKeyFixture.Passphrase}", + "--verify", + $"\"{GpgCli.NormalisePath(tamperedPath)}\"")); + } + finally { Cleanup(tamperedPath); } + } + finally { Cleanup(signedPath); } + } + + // ──────────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────────── + + private static string NewTempPath() => Path.Combine(Path.GetTempPath(), $"gpg_cli_{Guid.NewGuid():N}.bin"); + + private static void Cleanup(params string[] paths) + { + foreach (string p in paths) + if (File.Exists(p)) File.Delete(p); + } + } + + internal static class GpgCli + { + public static bool Probe() + { +#pragma warning disable CA1031 // Allow broad catch — Probe is a yes/no availability check; any failure (missing exe, permission denied, etc.) means "gpg unavailable". + try + { + var psi = MakeStartInfo("gpg", "--version"); + using var p = Process.Start(psi); + if (p == null) return false; + bool exited = p.WaitForExit(5000); + return exited && p.ExitCode == 0; + } + catch + { + return false; + } +#pragma warning restore CA1031 + } + + public static void Run(string homeDir, params string[] args) + { + var psi = MakeStartInfo("gpg", BuildArgs(homeDir, args)); + using var p = Process.Start(psi) + ?? throw new InvalidOperationException("Could not start gpg"); + if (!p.WaitForExit(20_000)) + { +#pragma warning disable CA1031 // Best-effort kill of a hung child process; any failure (already exited, denied) is swallowed before re-throwing the original timeout. + try { p.Kill(); } catch { } +#pragma warning restore CA1031 + throw new TimeoutException("gpg did not exit within 20s"); + } + if (p.ExitCode != 0) + { + string stderr = p.StandardError.ReadToEnd(); + throw new InvalidOperationException($"gpg {string.Join(' ', args)} → exit {p.ExitCode}: {stderr}"); + } + } + + public static string RunCapture(string homeDir, params string[] args) + { + var psi = MakeStartInfo("gpg", BuildArgs(homeDir, args)); + using var p = Process.Start(psi) + ?? throw new InvalidOperationException("Could not start gpg"); + string stdout = p.StandardOutput.ReadToEnd(); + if (!p.WaitForExit(20_000)) + { +#pragma warning disable CA1031 // Best-effort kill of a hung child process; any failure is swallowed before re-throwing the timeout. + try { p.Kill(); } catch { } +#pragma warning restore CA1031 + throw new TimeoutException("gpg did not exit within 20s"); + } + if (p.ExitCode != 0) + { + string stderr = p.StandardError.ReadToEnd(); + throw new InvalidOperationException($"gpg {string.Join(' ', args)} → exit {p.ExitCode}: {stderr}"); + } + return stdout; + } + + private static ProcessStartInfo MakeStartInfo(string fileName, string args) => new(fileName, args) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + + private static string BuildArgs(string homeDir, string[] args) + { + // Always isolate the homedir so tests can't pollute the user's keyring. + // The --homedir flag must come before subcommand arguments. Normalise to + // forward-slash paths because GnuPG-on-Windows-via-MSYS expects POSIX-style. + return $"--homedir \"{NormalisePath(homeDir)}\" {string.Join(' ', args)}"; + } + + // Some gpg builds on Windows (MSYS2 / Git-for-Windows) treat "C:\..." or "C:/..." + // as a RELATIVE path with a colon in it — they want Cygwin-style absolute paths + // like "/c/Users/...". Native Gpg4win accepts the regular Windows form. To work + // with both, we probe once: import an empty file from a known temp homedir and + // see whether gpg can find the keyring there. If the "/c/..." form succeeds where + // "C:/..." fails, use cygpath conversion. + private static readonly Lazy _useCygPath = new(DetectCygPathRequirement); + +#pragma warning disable CA1031 // Probe is a yes/no detector: any failure (gpg missing, IO denied) means "fall back to cygpath form". + private static bool DetectCygPathRequirement() + { + if (!OperatingSystem.IsWindows()) return false; + string probe = Path.Combine(Path.GetTempPath(), $"gpg_probe_{Guid.NewGuid():N}"); + try + { + Directory.CreateDirectory(probe); + // gpg --list-keys with a fresh homedir will create the keyring lazily — the + // SIDE EFFECT here is that gpg actually touches the homedir filesystem. If + // it ends up looking at "/C:/..." we know it didn't understand the path. + var psi = MakeStartInfo("gpg", $"--homedir \"{probe}\" --list-keys"); + using var p = Process.Start(psi); + p.WaitForExit(5000); + if (p.ExitCode == 0 && Directory.GetFiles(probe).Length + Directory.GetDirectories(probe).Length > 0) + return false; // Windows path worked. + return true; + } + catch { return true; } + finally { try { Directory.Delete(probe, recursive: true); } catch { } } + } +#pragma warning restore CA1031 + + public static string NormalisePath(string p) + { + if (string.IsNullOrEmpty(p)) return p; + if (!_useCygPath.Value) return p.Replace('\\', '/'); + + // Convert "C:\foo\bar" → "/c/foo/bar" for MSYS-based gpg. + string forward = p.Replace('\\', '/'); + if (forward.Length >= 2 && forward[1] == ':') + return $"/{char.ToLowerInvariant(forward[0])}{forward.Substring(2)}"; + return forward; + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/PasswordKeyTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/PasswordKeyTests.cs new file mode 100644 index 000000000..7087972c6 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/PasswordKeyTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Linq; +using System.Security; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Activities.API; +using UiPath.Cryptography.Enums; +using Xunit; + +namespace UiPath.Cryptography.Activities.API.Tests +{ + /// + /// Lifecycle and materialisation behaviour of . Pins + /// invariants that the round-trip tests in + /// do not exercise on their own — disposal, defensive copying, encoding fidelity. + /// +#pragma warning disable CS0618 // Tests intentionally use AES via the legacy enum. + public class PasswordKeyTests + { + private readonly CryptographyService _service = new CryptographyService(); + + // After Dispose(), the SecureString is gone and any further use of the key + // must throw rather than silently encrypt with an empty/all-zero key derivation. + [Fact] + public void Dispose_ThenEncrypt_Throws() + { + PasswordKey key = PasswordKey.FromPassword("disposable", Encoding.UTF8); + key.Dispose(); + + Should.Throw(() => + _service.EncryptBytes(new byte[] { 1, 2, 3 }, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key))); + } + + // The FromPassword(SecureString) factory defensively copies the input SecureString + // — pinned by disposing the caller's SecureString and confirming the PasswordKey still works. + [Fact] + public void FromPassword_SecureString_DefensiveCopy_SurvivesCallerDispose() + { + var external = ToSecureString("survives-dispose"); + PasswordKey key = PasswordKey.FromPassword(external, Encoding.UTF8); + external.Dispose(); + + byte[] cipher = _service.EncryptBytes(Encoding.UTF8.GetBytes("payload"), EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key)); + byte[] plain = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key)); + plain.ShouldBe(Encoding.UTF8.GetBytes("payload")); + } + + // Same instance, repeated use — materialisation must be deterministic. (Round-trip + // covers the encrypt-then-decrypt case once; this pins that *both* sides of a long + // pipeline that calls Encrypt/Decrypt multiple times keep getting the same key.) + [Fact] + public void RepeatedUse_ProducesSameKey() + { + PasswordKey key = PasswordKey.FromPassword("stable", Encoding.UTF8); + + byte[] cipherA = _service.EncryptBytes(Encoding.UTF8.GetBytes("payloadA"), EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key)); + byte[] cipherB = _service.EncryptBytes(Encoding.UTF8.GetBytes("payloadB"), EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(key)); + + // Both blobs decrypt under the same key on a single instance — pins that + // the materialiser is idempotent and the SecureString isn't consumed on first use. + _service.DecryptBytes(cipherA, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key)) + .ShouldBe(Encoding.UTF8.GetBytes("payloadA")); + _service.DecryptBytes(cipherB, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(key)) + .ShouldBe(Encoding.UTF8.GetBytes("payloadB")); + } + + // Different encodings produce different derived keys — pins that the Encoding + // parameter actually flows through to MaterialisePasswordBytes (and isn't accidentally + // ignored in favour of a hardcoded UTF-8 or Unicode default). + [Fact] + public void Encoding_IsHonoured() + { + // A password whose UTF-8 and Unicode (UTF-16LE) byte representations differ. + const string password = "ăîșțâ"; + PasswordKey utf8Key = PasswordKey.FromPassword(password, Encoding.UTF8); + PasswordKey unicodeKey = PasswordKey.FromPassword(password, Encoding.Unicode); + + byte[] cipher = _service.EncryptBytes(Encoding.UTF8.GetBytes("payload"), EncryptionAlgorithm.AESGCM, SymmetricEncryptOptions.Classic(utf8Key)); + + // Decrypt under the SAME encoding succeeds. + byte[] roundTrip = _service.DecryptBytes(cipher, EncryptionAlgorithm.AESGCM, SymmetricDecryptOptions.Classic(utf8Key)); + roundTrip.ShouldBe(Encoding.UTF8.GetBytes("payload")); + + // Decrypt under a DIFFERENT encoding fails — AEAD tag check rejects the wrong key. + Should.Throw(() => + _service.DecryptBytes(cipher, EncryptionAlgorithm.AESGCM, SymmetricDecryptOptions.Classic(unicodeKey))); + } + + // Two PasswordKey instances built from the same password+encoding derive the same key — + // pins that there's no per-instance salt or randomness in materialisation (the salt + // lives in the wire format, not in the key). + [Fact] + public void TwoInstances_SamePasswordSameEncoding_InterchangeableForDecrypt() + { + PasswordKey k1 = PasswordKey.FromPassword("interchangeable", Encoding.UTF8); + PasswordKey k2 = PasswordKey.FromPassword("interchangeable", Encoding.UTF8); + + byte[] cipher = _service.EncryptBytes(Encoding.UTF8.GetBytes("payload"), EncryptionAlgorithm.AES, SymmetricEncryptOptions.Classic(k1)); + byte[] plain = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Classic(k2)); + plain.ShouldBe(Encoding.UTF8.GetBytes("payload")); + } + + // Dispose() being called twice is safe — eager disposal in nested using blocks should + // not throw on the second hit. + [Fact] + public void Dispose_Idempotent() + { + PasswordKey key = PasswordKey.FromPassword("idempotent", Encoding.UTF8); + key.Dispose(); + Should.NotThrow(() => key.Dispose()); + } + + // ReleaseMaterialisedBytes zeroes the buffer so the freshly-allocated password bytes + // do not linger on the managed heap. Pinned to guard against a future refactor that + // accidentally removes the override (which would silently reintroduce the heap-residency bug). + [Fact] + public void ReleaseMaterialisedBytes_ZeroesTheBuffer() + { + PasswordKey key = PasswordKey.FromPassword("doesn't matter", Encoding.UTF8); + byte[] buffer = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }; + + key.ReleaseMaterialisedBytes(buffer); + + buffer.ShouldBe(new byte[8]); + } + + [Fact] + public void ReleaseMaterialisedBytes_NullOrEmpty_NoThrow() + { + PasswordKey key = PasswordKey.FromPassword("k", Encoding.UTF8); + Should.NotThrow(() => key.ReleaseMaterialisedBytes(null)); + Should.NotThrow(() => key.ReleaseMaterialisedBytes(Array.Empty())); + } + + // The whole point of UseKeyBytes is that a throwing callback still triggers release. + // Capture the materialised buffer from inside the lambda and assert it's zeroed after + // the throw propagates out — the regression we'd see if the finally were ever dropped. + [Fact] + public void UseKeyBytes_FuncThrows_StillReleasesBytes() + { + PasswordKey key = PasswordKey.FromPassword("password-to-clear", Encoding.UTF8); + byte[] capturedBuffer = null; + + Should.Throw(() => + key.UseKeyBytes(bytes => + { + capturedBuffer = bytes; + bytes.Any(b => b != 0).ShouldBeTrue("buffer should hold password bytes before the throw"); + throw new InvalidOperationException("forced failure inside dispatch"); + })); + + capturedBuffer.ShouldNotBeNull(); + capturedBuffer.ShouldBe(new byte[capturedBuffer.Length]); + } + + [Fact] + public void UseKeyBytes_NullFunc_Throws() + { + PasswordKey key = PasswordKey.FromPassword("k", Encoding.UTF8); + Should.Throw(() => key.UseKeyBytes(null)); + } + + // Per-call clearing in the service must not destroy the PasswordKey instance's own + // state — many service calls under the same key must keep working. Pins that the + // try/finally clearing in CryptographyService only touches the per-call buffer. + [Fact] + public void Service_ClearsPerCall_KeyInstanceSurvives_ManyOperations() + { + PasswordKey key = PasswordKey.FromPassword("survive-many-calls", Encoding.UTF8); + byte[] plain = Encoding.UTF8.GetBytes("payload"); + + for (int i = 0; i < 5; i++) + { + byte[] cipher = _service.EncryptBytes(plain, EncryptionAlgorithm.AESGCM, SymmetricEncryptOptions.Classic(key)); + byte[] roundTrip = _service.DecryptBytes(cipher, EncryptionAlgorithm.AESGCM, SymmetricDecryptOptions.Classic(key)); + roundTrip.ShouldBe(plain); + } + } + + private static SecureString ToSecureString(string value) + { + var ss = new SecureString(); + foreach (char c in value) ss.AppendChar(c); + ss.MakeReadOnly(); + return ss; + } + } +#pragma warning restore CS0618 +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/PgpPrivateKeyTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/PgpPrivateKeyTests.cs new file mode 100644 index 000000000..7e66cf8a2 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/PgpPrivateKeyTests.cs @@ -0,0 +1,148 @@ +using System; +using System.IO; +using System.Net; +using System.Security; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Activities.API; +using UiPath.Cryptography.Enums; +using Xunit; + +namespace UiPath.Cryptography.Activities.API.Tests +{ + /// + /// Coverage for and behaviours + /// not exercised by the round-trip tests in : + /// load-from-file, save-overwrite contract, and wrong-passphrase translation. + /// + public class PgpPrivateKeyTests : IClassFixture + { + private readonly CryptographyService _service = new CryptographyService(); + private readonly PgpKeyFixture _keys; + + public PgpPrivateKeyTests(PgpKeyFixture keys) => _keys = keys; + + // Save → FromFilePath round-trip. Today CryptographyServiceTests only exercises + // PgpPublicKey.FromFilePath; pin the private-key path the same way. + [Fact] + public void PrivateKey_SaveThenLoadFromFilePath_RoundTrips() + { + string privatePath = Path.Combine(Path.GetTempPath(), $"pgp_priv_{Guid.NewGuid():N}.asc"); + try + { + _keys.PrivateKey.Save(privatePath, overwrite: true); + File.Exists(privatePath).ShouldBeTrue(); + + using PgpPrivateKey reloaded = PgpPrivateKey.FromFilePath(privatePath, PgpKeyFixture.Passphrase); + + byte[] cipher = _service.PgpEncryptBytes(Encoding.UTF8.GetBytes("save-reload"), _keys.PublicKey); + byte[] plain = _service.PgpDecryptBytes(cipher, reloaded); + Encoding.UTF8.GetString(plain).ShouldBe("save-reload"); + } + finally + { + if (File.Exists(privatePath)) File.Delete(privatePath); + } + } + + [Fact] + public void PrivateKey_SaveOverwriteFalse_ExistingFile_Throws() + { + string path = Path.Combine(Path.GetTempPath(), $"pgp_priv_{Guid.NewGuid():N}.asc"); + File.WriteAllText(path, "stub-existing-content"); + try + { + Should.Throw(() => _keys.PrivateKey.Save(path, overwrite: false)); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + [Fact] + public void PrivateKey_SaveOverwriteTrue_ExistingFile_Overwrites() + { + string path = Path.Combine(Path.GetTempPath(), $"pgp_priv_{Guid.NewGuid():N}.asc"); + File.WriteAllText(path, "stub-existing-content"); + try + { + _keys.PrivateKey.Save(path, overwrite: true); + + // The file should now contain the actual private-key bytes. + byte[] onDisk = File.ReadAllBytes(path); + onDisk.ShouldBe(_keys.PrivateKeyBytes); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + // Wrong passphrase → BouncyCastle's "Checksum mismatch" gets translated by + // CryptographyHelper.TranslatePgpException into an InvalidOperationException with a + // clear hint. Pin the message substring so the hint doesn't regress to a generic exception. + [Fact] + public void PrivateKey_WrongPassphrase_Throws_TranslatedException() + { + using PgpPrivateKey wrong = PgpPrivateKey.FromBytes(_keys.PrivateKeyBytes, "definitely-not-the-passphrase"); + byte[] cipher = _service.PgpEncryptBytes(Encoding.UTF8.GetBytes("ok"), _keys.PublicKey); + + // The translated exception should be InvalidOperationException with a passphrase + // hint, not a raw BouncyCastle "Checksum mismatch". + InvalidOperationException ex = Should.Throw(() => + _service.PgpDecryptBytes(cipher, wrong)); + + // The hint should mention "passphrase" so the user knows what to fix. + ex.Message.ShouldContain("passphrase", Case.Insensitive); + } + + // FromFilePath validation — empty/whitespace path is caught at the factory. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void PrivateKey_FromFilePath_EmptyPath_Throws(string path) + { + Should.Throw(() => PgpPrivateKey.FromFilePath(path, "pwd")); + } + + // FromFilePath with SecureString passphrase — overload coverage. + [Fact] + public void PrivateKey_FromFilePath_WithSecureStringPassphrase_RoundTrips() + { + string path = Path.Combine(Path.GetTempPath(), $"pgp_priv_{Guid.NewGuid():N}.asc"); + try + { + _keys.PrivateKey.Save(path, overwrite: true); + SecureString securePass = new NetworkCredential(string.Empty, PgpKeyFixture.Passphrase).SecurePassword; + + using PgpPrivateKey loaded = PgpPrivateKey.FromFilePath(path, securePass); + + byte[] cipher = _service.PgpEncryptBytes(Encoding.UTF8.GetBytes("secure-load"), _keys.PublicKey); + byte[] plain = _service.PgpDecryptBytes(cipher, loaded); + Encoding.UTF8.GetString(plain).ShouldBe("secure-load"); + } + finally + { + if (File.Exists(path)) File.Delete(path); + } + } + + // ToBytes returns a defensive copy — pin that mutating the returned array + // doesn't corrupt subsequent encryption with the live PgpPrivateKey instance. + [Fact] + public void PrivateKey_ToBytes_ReturnsDefensiveCopy() + { + byte[] bytes1 = _keys.PrivateKey.ToBytes(); + byte[] bytes2 = _keys.PrivateKey.ToBytes(); + bytes1.ShouldBe(bytes2); + + // Mutate the first copy — second call must still return the canonical bytes. + Array.Clear(bytes1, 0, bytes1.Length); + byte[] bytes3 = _keys.PrivateKey.ToBytes(); + bytes3.ShouldBe(bytes2); + bytes3.ShouldNotBe(bytes1); + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/RawKeyTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/RawKeyTests.cs new file mode 100644 index 000000000..505038f84 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API.Tests/RawKeyTests.cs @@ -0,0 +1,148 @@ +using System; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Activities.API; +using UiPath.Cryptography.Enums; +using Xunit; + +namespace UiPath.Cryptography.Activities.API.Tests +{ + /// + /// Lifecycle and parsing behaviour of . Pins disposal, + /// defensive copying, and the hex-parsing tolerance documented in the XML docs. + /// +#pragma warning disable CS0618 // Tests intentionally use AES via the legacy enum. + public class RawKeyTests + { + private readonly CryptographyService _service = new CryptographyService(); + + // After Dispose(), the underlying byte[] has been zeroed and nulled. Subsequent + // use must throw rather than silently produce ciphertext under an all-zero key. + [Fact] + public void Dispose_ThenEncrypt_Throws() + { + RawKey key = RawKey.FromBytes(new byte[32]); + key.Dispose(); + + Should.Throw(() => + _service.EncryptBytes(new byte[] { 1, 2, 3 }, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Raw(key))); + } + + // FromBytes defensively copies the input so the caller can scrub their buffer + // (or reuse it) without affecting the RawKey instance. + [Fact] + public void FromBytes_DefensiveCopy_CallerMutationDoesNotAffectInstance() + { + byte[] sourceKey = new byte[32]; + for (int i = 0; i < 32; i++) sourceKey[i] = (byte)(i + 1); + + RawKey key = RawKey.FromBytes(sourceKey); + + byte[] cipher = _service.EncryptBytes(Encoding.UTF8.GetBytes("payload"), EncryptionAlgorithm.AES, SymmetricEncryptOptions.Raw(key)); + + // Scribble all over the caller's array — the instance must still decrypt successfully + // because it owns a defensive copy. + Array.Clear(sourceKey, 0, sourceKey.Length); + + byte[] plain = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Raw(key)); + plain.ShouldBe(Encoding.UTF8.GetBytes("payload")); + } + + // Dispose is safe to call twice (and zeroes are reflected through KeyBytes access via the throw). + [Fact] + public void Dispose_Idempotent() + { + RawKey key = RawKey.FromBytes(new byte[32]); + key.Dispose(); + Should.NotThrow(() => key.Dispose()); + } + + // RawKey.KeyBytes returns a reference to the instance's own storage, NOT a fresh copy. + // ReleaseMaterialisedBytes must therefore inherit the base CryptoKey no-op — clearing + // the buffer per call would zero the key itself and break subsequent operations. + [Fact] + public void ReleaseMaterialisedBytes_IsNoOp_DoesNotCorruptInstance() + { + byte[] original = new byte[32]; + for (int i = 0; i < 32; i++) original[i] = (byte)(i + 1); + RawKey key = RawKey.FromBytes(original); + + byte[] viewBefore = key.KeyBytes; + key.ReleaseMaterialisedBytes(viewBefore); + byte[] viewAfter = key.KeyBytes; + + // Both views point at the live storage and the storage is unchanged after the call. + viewAfter.ShouldBe(original); + } + + // ─────────────────────────────────────────────────────────────────────── + // Hex parsing tolerance — FromHex delegates to CryptographyHelper.ParseKeyBytes, + // which strips "0x" prefix and whitespace/colons/dashes per the helper's contract. + // Pin each tolerated variant so we don't silently regress. + // ─────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F")] // bare + [InlineData("0x000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F")] // 0x prefix + [InlineData("0X000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F")] // 0X prefix + [InlineData("00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F")] // whitespace + [InlineData("00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F")] // colons + [InlineData("00-01-02-03-04-05-06-07-08-09-0A-0B-0C-0D-0E-0F-10-11-12-13-14-15-16-17-18-19-1A-1B-1C-1D-1E-1F")] // dashes + [InlineData("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")] // lowercase + [InlineData("000102030405060708090A0B0C0D0E0F101112131415161718191a1b1c1d1e1f")] // mixed case + public void FromHex_TolerantVariants_ProduceEquivalentKey(string hex) + { + byte[] canonical = new byte[32]; + for (int i = 0; i < 32; i++) canonical[i] = (byte)i; + + RawKey hexKey = RawKey.FromHex(hex); + RawKey bytesKey = RawKey.FromBytes(canonical); + + byte[] plain = Encoding.UTF8.GetBytes("tolerance-check"); + byte[] cipherFromHex = _service.EncryptBytes(plain, EncryptionAlgorithm.AES, SymmetricEncryptOptions.Raw(hexKey)); + // Decrypt with the byte-equivalent key — proves both keys derive to the same bytes. + byte[] decryptedWithBytesKey = _service.DecryptBytes(cipherFromHex, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Raw(bytesKey)); + decryptedWithBytesKey.ShouldBe(plain); + } + + [Fact] + public void FromHex_OddLength_Throws() + { + // 63 hex digits — cleaned hex has odd length after the canonicalisation pass. + Should.Throw(() => RawKey.FromHex("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E0")); + } + + [Fact] + public void FromHex_NonHexCharacters_Throws() + { + // 'Z' is not a hex digit and not one of the tolerated separators (' ', ':', '-', whitespace). + Should.Throw(() => RawKey.FromHex("ZZ01020304050607080900ZZ010203040506070809000102030405060708090A")); + } + + // ─────────────────────────────────────────────────────────────────────── + // Base64 parsing — FromBase64 delegates to Convert.FromBase64String, which is + // strict about padding but tolerates standard alphabet. + // ─────────────────────────────────────────────────────────────────────── + + [Fact] + public void FromBase64_StandardPadding_Accepted() + { + byte[] canonical = new byte[32]; + for (int i = 0; i < 32; i++) canonical[i] = (byte)(i + 1); + string base64 = Convert.ToBase64String(canonical); + + RawKey k = RawKey.FromBase64(base64); + byte[] cipher = _service.EncryptBytes(Encoding.UTF8.GetBytes("payload"), EncryptionAlgorithm.AES, SymmetricEncryptOptions.Raw(k)); + byte[] plain = _service.DecryptBytes(cipher, EncryptionAlgorithm.AES, SymmetricDecryptOptions.Raw(k)); + plain.ShouldBe(Encoding.UTF8.GetBytes("payload")); + } + + [Fact] + public void FromBase64_InvalidCharacters_Throws() + { + // '*' is not in the Base64 alphabet. + Should.Throw(() => RawKey.FromBase64("****never-valid-base64****")); + } + } +#pragma warning restore CS0618 +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/CryptoKey.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/CryptoKey.cs new file mode 100644 index 000000000..380fb7de6 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/CryptoKey.cs @@ -0,0 +1,50 @@ +using System; + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// Base type for key material supplied to a symmetric or keyed-hash operation. Choose a + /// concrete subclass based on what the bytes represent: + /// + /// — low-entropy password material to be PBKDF2-stretched (used with , , ). + /// — a literal cipher key of the exact size required by the algorithm (used with ). + /// + /// The factory on the corresponding SymmetricEncryptOptions / SymmetricDecryptOptions + /// format takes the matching key type, so mismatched pairings fail at compile time. + /// + public abstract class CryptoKey + { + private protected CryptoKey() { } + + /// + /// Bytes the cipher will operate on. Each derived type owns its storage strategy — + /// see for the lazy SecureString-backed path and + /// for the literal-key path. + /// + internal abstract byte[] KeyBytes { get; } + + internal abstract bool IsRawKey { get; } + + internal abstract KeyBytesFormat BytesFormat { get; } + + /// + /// Release a buffer that handed out for a single operation. Default + /// is a no-op: returns its instance-owned storage, and clearing it + /// per call would corrupt the key. overrides this to zero the + /// returned buffer eagerly, so freshly-materialised password bytes do not linger on the + /// managed heap between operations. + /// + internal virtual void ReleaseMaterialisedBytes(byte[] bytes) { } + + // Scoped accessor: materialises KeyBytes, invokes 'use', and guarantees release even on throw. + // Existence prevents a future call site from forgetting the try/finally and silently leaking + // password bytes — the cleanup is owned here, not at every call site. + internal T UseKeyBytes(Func use) + { + ArgumentNullException.ThrowIfNull(use); + byte[] bytes = KeyBytes; + try { return use(bytes); } + finally { ReleaseMaterialisedBytes(bytes); } + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/CryptoOptions.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/CryptoOptions.cs new file mode 100644 index 000000000..2d36f7b68 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/CryptoOptions.cs @@ -0,0 +1,36 @@ +using System.Text; + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// Base for symmetric encrypt and decrypt options. Holds the fields shared between + /// directions: the key, the wire format, and the KDF iteration count. Construct via the + /// factory methods on / ; + /// the factories enforce the (key kind × wire format) pairing at compile time. + /// + public abstract class CryptoOptions + { + private protected CryptoOptions() { } + + /// Key material — a for KDF-based formats, a for . + public CryptoKey Key { get; private protected init; } + + /// Wire format produced / consumed. + public SymmetricWireFormat Format { get; private protected init; } = SymmetricWireFormat.Classic; + + /// + /// PBKDF2 iteration count for and + /// . Set by the format-specific factories. + /// Zero for Classic and Raw. + /// + public int KdfIterations { get; private protected init; } + + /// + /// Text encoding used by EncryptText / DecryptText to transcode the plaintext + /// to/from bytes. Defaults to ; pass a different encoding to + /// the format factory when migrating ciphertext produced by non-UTF-8 callers of the prior + /// coded API. Ignored by EncryptBytes / DecryptBytes / EncryptFile / DecryptFile. + /// + public Encoding TextEncoding { get; private protected init; } = Encoding.UTF8; + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PasswordKey.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PasswordKey.cs new file mode 100644 index 000000000..d2d763260 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PasswordKey.cs @@ -0,0 +1,135 @@ +using System; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// Password material to be stretched through PBKDF2 to derive the cipher key. + /// Compatible with , + /// , and . + /// + /// + /// + /// The password is stored internally as a . Plain bytes are + /// materialised just-in-time on every access — they live + /// only as long as the caller's local variable (in practice, the duration of a single + /// CryptographyService operation), never pinned to this instance. The materialiser + /// also zeroes the unmanaged Unicode buffer and the intermediate char[] in + /// finally; only the returned byte[] survives, governed by the caller's + /// stack lifetime. + /// + /// + /// eagerly zeroes the protected SecureString buffer and nulls the + /// stored reference; subsequent access throws + /// so a stale reference cannot silently produce + /// wrong ciphertext. Disposing is optional — the SecureString finalizer cleans up + /// eventually — but recommended for sensitive workflows. + /// + /// + public sealed class PasswordKey : CryptoKey, IDisposable + { + private SecureString _password; + private readonly Encoding _encoding; + + private PasswordKey(SecureString password, Encoding encoding) + { + _password = password; + _encoding = encoding; + } + + internal override bool IsRawKey => false; + + internal override KeyBytesFormat BytesFormat => KeyBytesFormat.Encoded; + + internal override byte[] KeyBytes + => MaterialisePasswordBytes(_password ?? throw new ObjectDisposedException(nameof(PasswordKey)), _encoding); + + // Each KeyBytes access returns a freshly-allocated buffer (see MaterialisePasswordBytes). + // After the caller has fed it into PBKDF2 / HMAC, zero it eagerly so the password + // bytes do not survive on the managed heap until the next GC pass. + internal override void ReleaseMaterialisedBytes(byte[] bytes) + { + if (bytes != null && bytes.Length > 0) + Array.Clear(bytes, 0, bytes.Length); + } + + public void Dispose() + { + if (_password != null) + { + _password.Dispose(); + _password = null; + } + } + + /// + /// Creates a from a plain-string password. The string is + /// copied into a internally — note that the caller's + /// original string is unaffected and continues to live on the managed heap until GC. + /// At each encrypt/decrypt operation, PBKDF2 stretches the bytes into the algorithm's + /// cipher key using the salt embedded in the wire format and the iteration count + /// from the / factory. + /// + /// Password material. Any length is accepted — PBKDF2 stretches. + /// Encoding used to transcode to bytes. Typically ; must match the encoding used at decrypt time. + /// Thrown when is null or empty. + /// Thrown when is null. + public static PasswordKey FromPassword(string password, Encoding encoding) + { + if (string.IsNullOrEmpty(password)) + throw new ArgumentException("Password must not be null or empty.", nameof(password)); + ArgumentNullException.ThrowIfNull(encoding); + + var ss = new SecureString(); + foreach (char c in password) + ss.AppendChar(c); + ss.MakeReadOnly(); + return new PasswordKey(ss, encoding); + } + + /// + /// Creates a from a password. + /// The caller's is copied (via ) + /// so disposing it externally does not affect this instance. Bytes are materialised + /// just-in-time on each encrypt/decrypt operation and do not persist on the heap + /// pinned to this object. + /// + /// Password material sourced from a secret store or user input. Any length accepted. + /// Encoding used to transcode to bytes. Typically ; must match the encoding used at decrypt time. + /// Thrown when or is null. + public static PasswordKey FromPassword(SecureString password, Encoding encoding) + { + ArgumentNullException.ThrowIfNull(password); + ArgumentNullException.ThrowIfNull(encoding); + return new PasswordKey(password.Copy(), encoding); + } + + // Materialise SecureString contents to bytes via a Marshal-based path. The unmanaged + // buffer and the intermediate char[] are zeroed in finally; only the returned byte[] + // persists, on the caller's stack lifetime. + private static byte[] MaterialisePasswordBytes(SecureString password, Encoding encoding) + { + if (password.Length == 0) + return Array.Empty(); + + IntPtr ptr = IntPtr.Zero; + char[] chars = null; + try + { + ptr = Marshal.SecureStringToGlobalAllocUnicode(password); + chars = new char[password.Length]; + Marshal.Copy(ptr, chars, 0, password.Length); + return encoding.GetBytes(chars); + } + finally + { + if (ptr != IntPtr.Zero) + Marshal.ZeroFreeGlobalAllocUnicode(ptr); + if (chars != null) + Array.Clear(chars, 0, chars.Length); + } + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpKeyPair.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpKeyPair.cs new file mode 100644 index 000000000..8077aed13 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpKeyPair.cs @@ -0,0 +1,31 @@ +using System; + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// A matched OpenPGP public/private key pair. The two halves are mathematically tied + /// to a single RNG draw and must come from the same PgpGenerateKeys call — + /// there is intentionally no API to construct one from independently-generated halves. + /// Call / on each + /// half to persist if needed. + /// + public sealed class PgpKeyPair + { + public PgpPublicKey PublicKey { get; } + public PgpPrivateKey PrivateKey { get; } + + public PgpKeyPair(PgpPublicKey publicKey, PgpPrivateKey privateKey) + { + ArgumentNullException.ThrowIfNull(publicKey); + ArgumentNullException.ThrowIfNull(privateKey); + PublicKey = publicKey; + PrivateKey = privateKey; + } + + public void Deconstruct(out PgpPublicKey publicKey, out PgpPrivateKey privateKey) + { + publicKey = PublicKey; + privateKey = PrivateKey; + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpPrivateKey.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpPrivateKey.cs new file mode 100644 index 000000000..b2a81600e --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpPrivateKey.cs @@ -0,0 +1,142 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Security; + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// Holds an OpenPGP private key plus its passphrase. The passphrase is bound to the + /// key at construction so callers cannot accidentally pair the wrong passphrase with + /// a private key elsewhere. Use the factory methods to load from bytes or a file path. + /// + /// + /// + /// The passphrase is stored as a for the lifetime of this + /// instance. It is materialised to a managed only when needed for a + /// cryptographic operation (BouncyCastle's API requires a plain string and offers no + /// byte[]-based passphrase API). The materialised string lives on the stack frame of the + /// service call — not on this object — so its heap residency is bounded by the operation + /// rather than the lifetime of the . + /// + /// + /// The materialised string still cannot be deterministically zeroed (it's a managed + /// immutable string passed into BouncyCastle). The improvement over storing a managed + /// string field is per-operation lifetime instead of per-object. + /// + /// + /// zeroes the protected passphrase buffer eagerly. The instance is + /// safe to use without disposing — the finalizer cleans up + /// eventually — but calling is recommended for sensitive workflows. + /// + /// + public sealed class PgpPrivateKey : IDisposable + { + private readonly byte[] _keyBytes; + private readonly SecureString _passphrase; + + private PgpPrivateKey(byte[] keyBytes, SecureString passphrase) + { + _keyBytes = keyBytes; + _passphrase = passphrase; + } + + public static PgpPrivateKey FromBytes(byte[] keyBytes, string passphrase) + { + ValidateBytes(keyBytes); + return new PgpPrivateKey(CopyBytes(keyBytes), StringToSecureString(passphrase)); + } + + public static PgpPrivateKey FromBytes(byte[] keyBytes, SecureString passphrase) + { + ValidateBytes(keyBytes); + ArgumentNullException.ThrowIfNull(passphrase); + return new PgpPrivateKey(CopyBytes(keyBytes), passphrase.Copy()); + } + + public static PgpPrivateKey FromFilePath(string path, string passphrase) + { + ValidatePath(path); + return new PgpPrivateKey(File.ReadAllBytes(path), StringToSecureString(passphrase)); + } + + public static PgpPrivateKey FromFilePath(string path, SecureString passphrase) + { + ValidatePath(path); + ArgumentNullException.ThrowIfNull(passphrase); + return new PgpPrivateKey(File.ReadAllBytes(path), passphrase.Copy()); + } + + public byte[] ToBytes() + { + byte[] copy = new byte[_keyBytes.Length]; + Buffer.BlockCopy(_keyBytes, 0, copy, 0, _keyBytes.Length); + return copy; + } + + public void Save(string filePath, bool overwrite = false) + { + if (string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("File path must not be null or empty.", nameof(filePath)); + if (!overwrite && File.Exists(filePath)) + throw new InvalidOperationException($"Output file already exists: {filePath}"); + File.WriteAllBytes(filePath, _keyBytes); + } + + public void Dispose() => _passphrase?.Dispose(); + + // Materialise the passphrase to a managed string just-in-time. The returned string + // lives in the caller's stack frame and falls out of scope at the end of the + // cryptographic operation — not pinned to this instance. + internal (Stream stream, string passphrase) Open() => + (new MemoryStream(_keyBytes, writable: false), MaterialiseSecureString(_passphrase)); + + private static void ValidateBytes(byte[] keyBytes) + { + if (keyBytes is null || keyBytes.Length == 0) + throw new ArgumentException("Private key bytes must not be null or empty.", nameof(keyBytes)); + } + + private static void ValidatePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Private key file path must not be null or empty.", nameof(path)); + } + + private static byte[] CopyBytes(byte[] src) + { + byte[] copy = new byte[src.Length]; + Buffer.BlockCopy(src, 0, copy, 0, src.Length); + return copy; + } + + private static SecureString StringToSecureString(string value) + { + var ss = new SecureString(); + if (!string.IsNullOrEmpty(value)) + { + foreach (char c in value) + ss.AppendChar(c); + } + ss.MakeReadOnly(); + return ss; + } + + private static string MaterialiseSecureString(SecureString value) + { + if (value is null || value.Length == 0) + return null; + IntPtr ptr = IntPtr.Zero; + try + { + ptr = Marshal.SecureStringToGlobalAllocUnicode(value); + return Marshal.PtrToStringUni(ptr); + } + finally + { + if (ptr != IntPtr.Zero) + Marshal.ZeroFreeGlobalAllocUnicode(ptr); + } + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpPublicKey.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpPublicKey.cs new file mode 100644 index 000000000..d8be0ebee --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/PgpPublicKey.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// Holds an OpenPGP public key in memory. Use the factory methods to load from + /// bytes or a file path; the same instance can be reused across multiple PGP + /// operations. + /// + public sealed class PgpPublicKey + { + private readonly byte[] _keyBytes; + + private PgpPublicKey(byte[] keyBytes) + { + _keyBytes = keyBytes; + } + + public static PgpPublicKey FromBytes(byte[] keyBytes) + { + if (keyBytes is null || keyBytes.Length == 0) + throw new ArgumentException("Public key bytes must not be null or empty.", nameof(keyBytes)); + byte[] copy = new byte[keyBytes.Length]; + Buffer.BlockCopy(keyBytes, 0, copy, 0, keyBytes.Length); + return new PgpPublicKey(copy); + } + + public static PgpPublicKey FromFilePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("Public key file path must not be null or empty.", nameof(path)); + return new PgpPublicKey(File.ReadAllBytes(path)); + } + + public byte[] ToBytes() + { + byte[] copy = new byte[_keyBytes.Length]; + Buffer.BlockCopy(_keyBytes, 0, copy, 0, _keyBytes.Length); + return copy; + } + + public void Save(string filePath, bool overwrite = false) + { + if (string.IsNullOrWhiteSpace(filePath)) + throw new ArgumentException("File path must not be null or empty.", nameof(filePath)); + if (!overwrite && File.Exists(filePath)) + throw new InvalidOperationException($"Output file already exists: {filePath}"); + File.WriteAllBytes(filePath, _keyBytes); + } + + internal Stream OpenStream() => new MemoryStream(_keyBytes, writable: false); + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/RawKey.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/RawKey.cs new file mode 100644 index 000000000..13a080859 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/RawKey.cs @@ -0,0 +1,102 @@ +using System; + +#pragma warning disable CS0618 // CryptographyHelper is intentionally marked Obsolete to discourage external use; in-package consumers are expected. + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// A literal cipher key — exact-length bytes used directly by the algorithm with no KDF. + /// Compatible only with . Length must match a legal + /// key size for the algorithm (e.g. 16/24/32 bytes for AES). + /// + /// + /// zeroes the held key bytes in place. The bytes are the cipher + /// key itself — disposing eagerly removes them from the managed heap (rather than waiting + /// for GC). After disposal, throws + /// so a stale reference cannot silently encrypt with an all-zero key. + /// + public sealed class RawKey : CryptoKey, IDisposable + { + private byte[] _keyBytes; + + private RawKey(byte[] keyBytes) + { + _keyBytes = keyBytes; + } + + internal override bool IsRawKey => true; + + internal override KeyBytesFormat BytesFormat => KeyBytesFormat.Hex; + + internal override byte[] KeyBytes + => _keyBytes ?? throw new ObjectDisposedException(nameof(RawKey)); + + public void Dispose() + { + if (_keyBytes != null) + { + Array.Clear(_keyBytes, 0, _keyBytes.Length); + _keyBytes = null; + } + } + + /// + /// Creates a from an in-memory byte array. The input is + /// defensively copied so subsequent mutations of the caller's buffer don't affect + /// this instance. + /// + /// + /// Literal cipher key bytes. Length must match a legal key size for the algorithm + /// the key will be used with (e.g. 16, 24, or 32 bytes for AES — the runtime + /// validator surfaces a mismatch as at the service + /// entry, not here). + /// + /// Thrown when is null or empty. + public static RawKey FromBytes(byte[] keyBytes) + { + if (keyBytes is null || keyBytes.Length == 0) + throw new ArgumentException("Raw key bytes must not be null or empty.", nameof(keyBytes)); + byte[] copy = new byte[keyBytes.Length]; + Buffer.BlockCopy(keyBytes, 0, copy, 0, keyBytes.Length); + return new RawKey(copy); + } + + /// + /// Creates a by decoding a hex string into bytes. Use when the + /// key arrives as a hex literal (e.g. from configuration, an HTTP response, or the + /// output of openssl rand -hex N). + /// + /// + /// Hex-encoded key. Must contain only hex digits (0-9, a-f, A-F) and have even + /// length. The decoded byte length must match a legal key size for the algorithm + /// (validated at the service entry, not here). + /// + /// Thrown when is null or empty. + /// Thrown when is not a valid hex string. + public static RawKey FromHex(string hex) + { + if (string.IsNullOrEmpty(hex)) + throw new ArgumentException("Hex string must not be null or empty.", nameof(hex)); + return new RawKey(CryptographyHelper.ParseKeyBytes(hex, null, KeyBytesFormat.Hex, null)); + } + + /// + /// Creates a by decoding a Base64 string into bytes. Use when + /// the key arrives Base64-encoded (e.g. JSON config, JWT-style payload, or the + /// output of openssl rand -base64 N). + /// + /// + /// Base64-encoded key, optionally padded with '='. The decoded byte length must + /// match a legal key size for the algorithm (validated at the service entry, not + /// here). + /// + /// Thrown when is null or empty. + /// Thrown when is not a valid Base64 string. + public static RawKey FromBase64(string base64) + { + if (string.IsNullOrEmpty(base64)) + throw new ArgumentException("Base64 string must not be null or empty.", nameof(base64)); + return new RawKey(CryptographyHelper.ParseKeyBytes(base64, null, KeyBytesFormat.Base64, null)); + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/SymmetricDecryptOptions.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/SymmetricDecryptOptions.cs new file mode 100644 index 000000000..a89e6e869 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/SymmetricDecryptOptions.cs @@ -0,0 +1,49 @@ +using System.Text; + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// Options for symmetric decrypt operations. The IV is read from the ciphertext stream on + /// decrypt, so there is no IV field on this type. Construct via a format factory; + /// the factory's key parameter type enforces the (key kind × wire format) pairing at + /// compile time. + /// + public sealed class SymmetricDecryptOptions : CryptoOptions + { + private SymmetricDecryptOptions() { } + + /// Decrypts ciphertext. + /// Password material; must match the key used at encrypt time. + /// Plaintext encoding for DecryptText. Null defaults to ; ignored by DecryptBytes / DecryptFile. + public static SymmetricDecryptOptions Classic(PasswordKey key, Encoding encoding = null) => + new() { Key = key, Format = SymmetricWireFormat.Classic, TextEncoding = encoding ?? Encoding.UTF8 }; + + /// Decrypts ciphertext. + /// Password material; must match the key used at encrypt time. + /// + /// PBKDF2 iteration count. Must match the value used at encrypt time. + /// Defaults to 1_300_000 — the OWASP 2026 recommendation for PBKDF2-HMAC-SHA1. + /// + /// Plaintext encoding for DecryptText. Null defaults to ; ignored by DecryptBytes / DecryptFile. + public static SymmetricDecryptOptions Owasp2026(PasswordKey key, int kdfIterations = 1_300_000, Encoding encoding = null) => + new() { Key = key, Format = SymmetricWireFormat.Owasp2026, KdfIterations = kdfIterations, TextEncoding = encoding ?? Encoding.UTF8 }; + + /// Decrypts ciphertext (the IV is read from the prefix of the stream). + /// Literal cipher key — must be the same bytes used at encrypt time. + /// Plaintext encoding for DecryptText. Null defaults to ; ignored by DecryptBytes / DecryptFile. + public static SymmetricDecryptOptions Raw(RawKey key, Encoding encoding = null) => + new() { Key = key, Format = SymmetricWireFormat.Raw, TextEncoding = encoding ?? Encoding.UTF8 }; + + /// Decrypts ciphertext. + /// Password material; must match the key used at encrypt time. + /// + /// PBKDF2 iteration count. Must match the value used at encrypt time. + /// Defaults to 600_000 — the OWASP 2026 recommendation for PBKDF2-HMAC-SHA256. + /// Pass 10_000 when reading output produced by openssl enc -pbkdf2 without an explicit + /// -iter flag (openssl's back-compat default). + /// + /// Plaintext encoding for DecryptText. Null defaults to ; ignored by DecryptBytes / DecryptFile. + public static SymmetricDecryptOptions OpenSslEnc(PasswordKey key, int kdfIterations = 600_000, Encoding encoding = null) => + new() { Key = key, Format = SymmetricWireFormat.OpenSslEnc, KdfIterations = kdfIterations, TextEncoding = encoding ?? Encoding.UTF8 }; + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/SymmetricEncryptOptions.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/SymmetricEncryptOptions.cs new file mode 100644 index 000000000..182ea23b5 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Models/SymmetricEncryptOptions.cs @@ -0,0 +1,73 @@ +using System.Text; + +namespace UiPath.Cryptography.Activities.API +{ + /// + /// Options for symmetric encrypt operations. Bundles the key, wire format, and any + /// format-specific knobs (IV for , KDF iterations + /// for / ). + /// Construct via a format factory; the factory's key parameter type enforces the + /// (key kind × wire format) pairing at compile time. + /// + public sealed class SymmetricEncryptOptions : CryptoOptions + { + private SymmetricEncryptOptions() { } + + /// + /// Explicit initialization vector for . + /// Null (default) means generate a random IV. Set only by the + /// factory. + /// + public byte[] IV { get; private init; } + + /// Produces output — UiPath's frozen, byte-stable layout (PBKDF2-HMAC-SHA1 @ 10 000 iter). + /// Password material; PBKDF2 stretches it to the cipher key. + /// Plaintext encoding for EncryptText. Null defaults to ; ignored by EncryptBytes / EncryptFile. + public static SymmetricEncryptOptions Classic(PasswordKey key, Encoding encoding = null) => + new() { Key = key, Format = SymmetricWireFormat.Classic, TextEncoding = encoding ?? Encoding.UTF8 }; + + /// Produces output (Classic wire layout, PBKDF2-HMAC-SHA1). + /// Password material; PBKDF2 stretches it to the cipher key. + /// + /// PBKDF2 iteration count. Defaults to 1_300_000 — the OWASP 2026 recommendation + /// for PBKDF2-HMAC-SHA1. The literal is part of the year-snapshot contract: if OWASP + /// revises the recommendation, this package adds a new + /// entry (e.g. Owasp2030) rather than changing this default. + /// + /// Plaintext encoding for EncryptText. Null defaults to ; ignored by EncryptBytes / EncryptFile. + public static SymmetricEncryptOptions Owasp2026(PasswordKey key, int kdfIterations = 1_300_000, Encoding encoding = null) => + new() { Key = key, Format = SymmetricWireFormat.Owasp2026, KdfIterations = kdfIterations, TextEncoding = encoding ?? Encoding.UTF8 }; + + /// + /// Produces output (caller-supplied key + IV, no KDF). + /// + /// Literal cipher key of the algorithm's required size (e.g. 32 bytes for AES-256). + /// + /// Optional explicit IV. Null (the default) lets the cipher generate one. + /// + /// ⚠ NEVER reuse the same (Key, IV) pair across encryptions. Doing so destroys + /// confidentiality (CTR keystream reuse) and — under AEAD modes (AES-GCM, + /// ChaCha20-Poly1305) — additionally lets an attacker recover the authentication key + /// and forge arbitrary messages under that key. Prefer leaving + /// null so a fresh random IV is generated per call; only supply an explicit IV when a + /// third-party protocol mandates it, and ensure your producer guarantees uniqueness. + /// + /// + /// Plaintext encoding for EncryptText. Null defaults to ; ignored by EncryptBytes / EncryptFile. + public static SymmetricEncryptOptions Raw(RawKey key, byte[] iv = null, Encoding encoding = null) => + new() { Key = key, Format = SymmetricWireFormat.Raw, IV = iv, TextEncoding = encoding ?? Encoding.UTF8 }; + + /// Produces output (openssl enc-compatible, PBKDF2-HMAC-SHA256). + /// Password material; PBKDF2-SHA256 stretches it to key+IV. + /// + /// PBKDF2 iteration count. Defaults to 600_000 — the OWASP 2026 recommendation + /// for PBKDF2-HMAC-SHA256. (Note: openssl enc's own default is 10 000 for + /// back-compat; the OWASP-aligned default produces stronger output but is still + /// decryptable by openssl enc -pbkdf2 -iter 600000 -md sha256.) Pass 10_000 + /// to match the openssl back-compat default explicitly. + /// + /// Plaintext encoding for EncryptText. Null defaults to ; ignored by EncryptBytes / EncryptFile. + public static SymmetricEncryptOptions OpenSslEnc(PasswordKey key, int kdfIterations = 600_000, Encoding encoding = null) => + new() { Key = key, Format = SymmetricWireFormat.OpenSslEnc, KdfIterations = kdfIterations, TextEncoding = encoding ?? Encoding.UTF8 }; + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/CryptographyService.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/CryptographyService.cs index 535d32dc4..36a9dbe3a 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/CryptographyService.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/CryptographyService.cs @@ -11,708 +11,333 @@ namespace UiPath.Cryptography.Activities.API { internal class CryptographyService : ICryptographyService { - // ── Symmetric encrypt ──────────────────────────────────────────────── + // ── Symmetric ───────────────────────────────────────────────────────── - public byte[] EncryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, string key, Encoding encoding) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - return CryptographyHelper.EncryptData(algorithm, inputBytes, keyBytes); - } - - public byte[] EncryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - return EncryptBytes(inputBytes, algorithm, keyBytes); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } - } - - public byte[] EncryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, byte[] keyBytes) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - return CryptographyHelper.EncryptData(algorithm, inputBytes, keyBytes); - } - - - public string EncryptText(string input, EncryptionAlgorithm algorithm, string key, Encoding encoding) + public byte[] EncryptBytes(byte[] input, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options) { ArgumentNullException.ThrowIfNull(input); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - byte[] inputBytes = encoding.GetBytes(input); - byte[] encryptedBytes = CryptographyHelper.EncryptData(algorithm, inputBytes, keyBytes); - return Convert.ToBase64String(encryptedBytes); + ArgumentNullException.ThrowIfNull(options); + ValidateSymmetric(algorithm, options, options.IV); + return options.Key.UseKeyBytes(keyBytes => + SymmetricInteropHelper.DispatchEncrypt(algorithm, options.Format, options.KdfIterations, keyBytes, options.IV, input)); } - public string EncryptText(string input, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - return EncryptText(input, algorithm, keyBytes, encoding); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } - } - - public string EncryptText(string input, EncryptionAlgorithm algorithm, byte[] keyBytes, Encoding encoding) + public byte[] DecryptBytes(byte[] input, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options) { ArgumentNullException.ThrowIfNull(input); - ArgumentNullException.ThrowIfNull(encoding); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - - byte[] inputBytes = encoding.GetBytes(input); - byte[] encryptedBytes = CryptographyHelper.EncryptData(algorithm, inputBytes, keyBytes); - return Convert.ToBase64String(encryptedBytes); - } - - - public void EncryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, string key, Encoding encoding, bool overwrite = false) - { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - ThrowIfFilePathMissing(outputFilePath, nameof(outputFilePath)); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - byte[] encryptedBytes = CryptographyHelper.EncryptData(algorithm, inputBytes, keyBytes); - WriteFile(outputFilePath, encryptedBytes, overwrite); - } - - public void EncryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding, bool overwrite = false) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - EncryptFile(inputFilePath, outputFilePath, algorithm, keyBytes, overwrite); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } - } - - public void EncryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, byte[] keyBytes, bool overwrite = false) - { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - ThrowIfFilePathMissing(outputFilePath, nameof(outputFilePath)); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - byte[] encryptedBytes = CryptographyHelper.EncryptData(algorithm, inputBytes, keyBytes); - WriteFile(outputFilePath, encryptedBytes, overwrite); - } - - // ── Symmetric decrypt ──────────────────────────────────────────────── - - public byte[] DecryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, string key, Encoding encoding) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - return CryptographyHelper.DecryptData(algorithm, inputBytes, keyBytes); - } - - public byte[] DecryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - return DecryptBytes(inputBytes, algorithm, keyBytes); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } + ArgumentNullException.ThrowIfNull(options); + ValidateSymmetric(algorithm, options, iv: null); + return options.Key.UseKeyBytes(keyBytes => + SymmetricInteropHelper.DispatchDecrypt(algorithm, options.Format, options.KdfIterations, keyBytes, input)); } - public byte[] DecryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, byte[] keyBytes) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - return CryptographyHelper.DecryptData(algorithm, inputBytes, keyBytes); - } - - - public string DecryptText(string input, EncryptionAlgorithm algorithm, string key, Encoding encoding) + public string EncryptText(string input, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options) { ArgumentNullException.ThrowIfNull(input); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - byte[] inputBytes = Convert.FromBase64String(input); - byte[] decryptedBytes = CryptographyHelper.DecryptData(algorithm, inputBytes, keyBytes); - return encoding.GetString(decryptedBytes); - } - - public string DecryptText(string input, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - return DecryptText(input, algorithm, keyBytes, encoding); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } + ArgumentNullException.ThrowIfNull(options); + byte[] cipher = EncryptBytes(options.TextEncoding.GetBytes(input), algorithm, options); + return Convert.ToBase64String(cipher); } - public string DecryptText(string input, EncryptionAlgorithm algorithm, byte[] keyBytes, Encoding encoding) + public string DecryptText(string input, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options) { ArgumentNullException.ThrowIfNull(input); - ArgumentNullException.ThrowIfNull(encoding); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - - byte[] inputBytes = Convert.FromBase64String(input); - byte[] decryptedBytes = CryptographyHelper.DecryptData(algorithm, inputBytes, keyBytes); - return encoding.GetString(decryptedBytes); + ArgumentNullException.ThrowIfNull(options); + byte[] plain = DecryptBytes(Convert.FromBase64String(input), algorithm, options); + return options.TextEncoding.GetString(plain); } - - public void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, string key, Encoding encoding, bool overwrite = false) + public void EncryptFile(string inputPath, string outputPath, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options, bool overwrite = false) { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - ThrowIfFilePathMissing(outputFilePath, nameof(outputFilePath)); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - byte[] decryptedBytes = CryptographyHelper.DecryptData(algorithm, inputBytes, keyBytes); - WriteFile(outputFilePath, decryptedBytes, overwrite); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + byte[] cipher = EncryptBytes(File.ReadAllBytes(inputPath), algorithm, options); + WriteFile(outputPath, cipher, overwrite); } - public void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding, bool overwrite = false) + public void DecryptFile(string inputPath, string outputPath, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options, bool overwrite = false) { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - DecryptFile(inputFilePath, outputFilePath, algorithm, keyBytes, overwrite); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } - } - - public void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, byte[] keyBytes, bool overwrite = false) - { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - ThrowIfFilePathMissing(outputFilePath, nameof(outputFilePath)); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - byte[] decryptedBytes = CryptographyHelper.DecryptData(algorithm, inputBytes, keyBytes); - WriteFile(outputFilePath, decryptedBytes, overwrite); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + byte[] plain = DecryptBytes(File.ReadAllBytes(inputPath), algorithm, options); + WriteFile(outputPath, plain, overwrite); } // ── Keyed hash ──────────────────────────────────────────────────────── - public string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, string key, Encoding encoding) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - return ComputeHashHex(algorithm, inputBytes, keyBytes); - } - - public string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - return KeyedHashBytes(inputBytes, algorithm, keyBytes); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } - } - - public string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, byte[] keyBytes) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - return ComputeHashHex(algorithm, inputBytes, keyBytes); - } - - - public string KeyedHashText(string input, KeyedHashAlgorithms algorithm, string key, Encoding encoding) + public string KeyedHashBytes(byte[] input, KeyedHashAlgorithms algorithm, CryptoKey key) { ArgumentNullException.ThrowIfNull(input); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - byte[] inputBytes = encoding.GetBytes(input); - return ComputeHashHex(algorithm, inputBytes, keyBytes); - } - - public string KeyedHashText(string input, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding) - { ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - return KeyedHashText(input, algorithm, keyBytes, encoding); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } + return key.UseKeyBytes(keyBytes => ComputeHashHex(algorithm, input, keyBytes)); } - public string KeyedHashText(string input, KeyedHashAlgorithms algorithm, byte[] keyBytes, Encoding encoding) + public string KeyedHashText(string input, KeyedHashAlgorithms algorithm, CryptoKey key) { ArgumentNullException.ThrowIfNull(input); - ArgumentNullException.ThrowIfNull(encoding); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - - byte[] inputBytes = encoding.GetBytes(input); - return ComputeHashHex(algorithm, inputBytes, keyBytes); - } - - - public string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, string key, Encoding encoding) - { - ThrowIfFilePathMissing(filePath, nameof(filePath)); - ArgumentNullException.ThrowIfNull(encoding); - if (string.IsNullOrEmpty(key)) - throw new ArgumentException("Key must not be null or empty.", nameof(key)); - - byte[] keyBytes = CryptographyHelper.KeyEncoding(encoding, key, null); - byte[] inputBytes = File.ReadAllBytes(filePath); - return ComputeHashHex(algorithm, inputBytes, keyBytes); - } - - public string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding) - { ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(encoding); - byte[] keyBytes = SecureStringToBytes(key, encoding); - try - { - return KeyedHashFile(filePath, algorithm, keyBytes); - } - finally - { - Array.Clear(keyBytes, 0, keyBytes.Length); - } + return key.UseKeyBytes(keyBytes => ComputeHashHex(algorithm, Encoding.UTF8.GetBytes(input), keyBytes)); } - public string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, byte[] keyBytes) + public string KeyedHashFile(string inputPath, KeyedHashAlgorithms algorithm, CryptoKey key) { - ThrowIfFilePathMissing(filePath, nameof(filePath)); - ThrowIfKeyMissing(keyBytes, nameof(keyBytes)); - - byte[] inputBytes = File.ReadAllBytes(filePath); - return ComputeHashHex(algorithm, inputBytes, keyBytes); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + ArgumentNullException.ThrowIfNull(key); + return key.UseKeyBytes(keyBytes => ComputeHashHex(algorithm, File.ReadAllBytes(inputPath), keyBytes)); } // ── PGP encrypt ─────────────────────────────────────────────────────── - // SecureString-passphrase overloads materialise to a managed string for the - // duration of the call. BouncyCastle requires a plain string passphrase and - // offers no byte[]-based API; the managed string cannot be zeroed afterward. - public byte[] PgpEncryptBytes(byte[] inputBytes, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false) + public byte[] PgpEncryptBytes(byte[] input, PgpPublicKey recipient, PgpPrivateKey signer = null) { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(publicKey, nameof(publicKey)); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(recipient); - using var pubStream = new MemoryStream(publicKey, writable: false); - using var privStream = ToReadOnlyStream(privateKey); - return CryptographyHelper.PgpEncrypt(inputBytes, pubStream, privStream, passphrase, sign); - } + using var pubStream = recipient.OpenStream(); + if (signer is null) + return CryptographyHelper.PgpEncrypt(input, pubStream, null, null, sign: false); - public byte[] PgpEncryptBytes(byte[] inputBytes, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false) - { - ArgumentNullException.ThrowIfNull(passphrase); - return PgpEncryptBytes(inputBytes, publicKey, privateKey, SecureStringToManagedString(passphrase), sign); + var (privStream, passphrase) = signer.Open(); + using (privStream) + return CryptographyHelper.PgpEncrypt(input, pubStream, privStream, passphrase, sign: true); } - - public string PgpEncryptText(string input, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false) + public string PgpEncryptText(string input, PgpPublicKey recipient, PgpPrivateKey signer = null) { ArgumentNullException.ThrowIfNull(input); - ThrowIfKeyMissing(publicKey, nameof(publicKey)); + ArgumentNullException.ThrowIfNull(recipient); - using var pubStream = new MemoryStream(publicKey, writable: false); - using var privStream = ToReadOnlyStream(privateKey); - return CryptographyHelper.PgpEncryptText(input, pubStream, privStream, passphrase, sign); - } + using var pubStream = recipient.OpenStream(); + if (signer is null) + return CryptographyHelper.PgpEncryptText(input, pubStream, null, null, sign: false); - public string PgpEncryptText(string input, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false) - { - ArgumentNullException.ThrowIfNull(passphrase); - return PgpEncryptText(input, publicKey, privateKey, SecureStringToManagedString(passphrase), sign); + var (privStream, passphrase) = signer.Open(); + using (privStream) + return CryptographyHelper.PgpEncryptText(input, pubStream, privStream, passphrase, sign: true); } - - public void PgpEncryptFile(string inputFilePath, string outputFilePath, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false, bool overwrite = false) + public void PgpEncryptFile(string inputPath, string outputPath, PgpPublicKey recipient, PgpPrivateKey signer = null, bool overwrite = false) { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - byte[] encrypted = PgpEncryptBytes(inputBytes, publicKey, privateKey, passphrase, sign); - WriteFile(outputFilePath, encrypted, overwrite); - } - - public void PgpEncryptFile(string inputFilePath, string outputFilePath, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false, bool overwrite = false) - { - ArgumentNullException.ThrowIfNull(passphrase); - PgpEncryptFile(inputFilePath, outputFilePath, publicKey, privateKey, SecureStringToManagedString(passphrase), sign, overwrite); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + byte[] encrypted = PgpEncryptBytes(File.ReadAllBytes(inputPath), recipient, signer); + WriteFile(outputPath, encrypted, overwrite); } // ── PGP decrypt ─────────────────────────────────────────────────────── - public byte[] PgpDecryptBytes(byte[] inputBytes, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(privateKey, nameof(privateKey)); - - using var privStream = new MemoryStream(privateKey, writable: false); - using var pubStream = ToReadOnlyStream(publicKey); - return CryptographyHelper.PgpDecrypt(inputBytes, privStream, passphrase, pubStream, verifySignature); - } - - public byte[] PgpDecryptBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false) - { - ArgumentNullException.ThrowIfNull(passphrase); - return PgpDecryptBytes(inputBytes, privateKey, SecureStringToManagedString(passphrase), publicKey, verifySignature); - } - - - public string PgpDecryptText(string input, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false) + public byte[] PgpDecryptBytes(byte[] input, PgpPrivateKey recipient, PgpPublicKey verifier = null) { ArgumentNullException.ThrowIfNull(input); - ThrowIfKeyMissing(privateKey, nameof(privateKey)); + ArgumentNullException.ThrowIfNull(recipient); - using var privStream = new MemoryStream(privateKey, writable: false); - using var pubStream = ToReadOnlyStream(publicKey); - return CryptographyHelper.PgpDecryptText(input, privStream, passphrase, pubStream, verifySignature); + var (privStream, passphrase) = recipient.Open(); + using (privStream) + using (Stream pubStream = verifier?.OpenStream()) + return CryptographyHelper.PgpDecrypt(input, privStream, passphrase, pubStream, verifySignature: verifier != null); } - public string PgpDecryptText(string input, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false) + public string PgpDecryptText(string input, PgpPrivateKey recipient, PgpPublicKey verifier = null) { - ArgumentNullException.ThrowIfNull(passphrase); - return PgpDecryptText(input, privateKey, SecureStringToManagedString(passphrase), publicKey, verifySignature); - } - + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(recipient); - public void PgpDecryptFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false, bool overwrite = false) - { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - byte[] decrypted = PgpDecryptBytes(inputBytes, privateKey, passphrase, publicKey, verifySignature); - WriteFile(outputFilePath, decrypted, overwrite); + var (privStream, passphrase) = recipient.Open(); + using (privStream) + using (Stream pubStream = verifier?.OpenStream()) + return CryptographyHelper.PgpDecryptText(input, privStream, passphrase, pubStream, verifySignature: verifier != null); } - public void PgpDecryptFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false, bool overwrite = false) + public void PgpDecryptFile(string inputPath, string outputPath, PgpPrivateKey recipient, PgpPublicKey verifier = null, bool overwrite = false) { - ArgumentNullException.ThrowIfNull(passphrase); - PgpDecryptFile(inputFilePath, outputFilePath, privateKey, SecureStringToManagedString(passphrase), publicKey, verifySignature, overwrite); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + byte[] decrypted = PgpDecryptBytes(File.ReadAllBytes(inputPath), recipient, verifier); + WriteFile(outputPath, decrypted, overwrite); } // ── PGP sign ────────────────────────────────────────────────────────── - public byte[] PgpSignBytes(byte[] inputBytes, byte[] privateKey, string passphrase) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(privateKey, nameof(privateKey)); - - using var privStream = new MemoryStream(privateKey, writable: false); - return CryptographyHelper.PgpSign(inputBytes, privStream, passphrase); - } - - public byte[] PgpSignBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase) - { - ArgumentNullException.ThrowIfNull(passphrase); - return PgpSignBytes(inputBytes, privateKey, SecureStringToManagedString(passphrase)); - } - - - public string PgpSignText(string input, byte[] privateKey, string passphrase) + public byte[] PgpSignBytes(byte[] input, PgpPrivateKey signer) { ArgumentNullException.ThrowIfNull(input); - ThrowIfKeyMissing(privateKey, nameof(privateKey)); + ArgumentNullException.ThrowIfNull(signer); - using var privStream = new MemoryStream(privateKey, writable: false); - return CryptographyHelper.PgpSignText(input, privStream, passphrase); + var (privStream, passphrase) = signer.Open(); + using (privStream) + return CryptographyHelper.PgpSign(input, privStream, passphrase); } - public string PgpSignText(string input, byte[] privateKey, SecureString passphrase) + public string PgpSignText(string input, PgpPrivateKey signer) { - ArgumentNullException.ThrowIfNull(passphrase); - return PgpSignText(input, privateKey, SecureStringToManagedString(passphrase)); - } - - - public void PgpSignFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, bool overwrite = false) - { - byte[] signed = PgpSignBytesFromFile(inputFilePath, privateKey, passphrase, sign: true); - WriteFile(outputFilePath, signed, overwrite); - } - - public void PgpSignFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, bool overwrite = false) - { - ArgumentNullException.ThrowIfNull(passphrase); - PgpSignFile(inputFilePath, outputFilePath, privateKey, SecureStringToManagedString(passphrase), overwrite); - } - - // ── PGP clearsign ───────────────────────────────────────────────────── - - public byte[] PgpClearsignBytes(byte[] inputBytes, byte[] privateKey, string passphrase) - { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(privateKey, nameof(privateKey)); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(signer); - using var privStream = new MemoryStream(privateKey, writable: false); - return CryptographyHelper.PgpClearSign(inputBytes, privStream, passphrase); + var (privStream, passphrase) = signer.Open(); + using (privStream) + return CryptographyHelper.PgpSignText(input, privStream, passphrase); } - public byte[] PgpClearsignBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase) + public void PgpSignFile(string inputPath, string outputPath, PgpPrivateKey signer, bool overwrite = false) { - ArgumentNullException.ThrowIfNull(passphrase); - return PgpClearsignBytes(inputBytes, privateKey, SecureStringToManagedString(passphrase)); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + byte[] signed = PgpSignBytes(File.ReadAllBytes(inputPath), signer); + WriteFile(outputPath, signed, overwrite); } + // ── PGP clear-sign ──────────────────────────────────────────────────── - public string PgpClearsignText(string input, byte[] privateKey, string passphrase) + public byte[] PgpClearSignBytes(byte[] input, PgpPrivateKey signer) { ArgumentNullException.ThrowIfNull(input); - ThrowIfKeyMissing(privateKey, nameof(privateKey)); + ArgumentNullException.ThrowIfNull(signer); - using var privStream = new MemoryStream(privateKey, writable: false); - return CryptographyHelper.PgpClearSignText(input, privStream, passphrase); + var (privStream, passphrase) = signer.Open(); + using (privStream) + return CryptographyHelper.PgpClearSign(input, privStream, passphrase); } - public string PgpClearsignText(string input, byte[] privateKey, SecureString passphrase) + public string PgpClearSignText(string input, PgpPrivateKey signer) { - ArgumentNullException.ThrowIfNull(passphrase); - return PgpClearsignText(input, privateKey, SecureStringToManagedString(passphrase)); - } - + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(signer); - public void PgpClearsignFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, bool overwrite = false) - { - byte[] signed = PgpSignBytesFromFile(inputFilePath, privateKey, passphrase, sign: false); - WriteFile(outputFilePath, signed, overwrite); + var (privStream, passphrase) = signer.Open(); + using (privStream) + return CryptographyHelper.PgpClearSignText(input, privStream, passphrase); } - public void PgpClearsignFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, bool overwrite = false) + public void PgpClearSignFile(string inputPath, string outputPath, PgpPrivateKey signer, bool overwrite = false) { - ArgumentNullException.ThrowIfNull(passphrase); - PgpClearsignFile(inputFilePath, outputFilePath, privateKey, SecureStringToManagedString(passphrase), overwrite); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + byte[] signed = PgpClearSignBytes(File.ReadAllBytes(inputPath), signer); + WriteFile(outputPath, signed, overwrite); } - // ── PGP verify (binary signature) ───────────────────────────────────── + // ── PGP verify (binary) ─────────────────────────────────────────────── - public bool PgpVerifyBytes(byte[] inputBytes, byte[] publicKey) + public bool PgpVerifyBytes(byte[] input, PgpPublicKey verifier) { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(publicKey, nameof(publicKey)); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(verifier); - using var pubStream = new MemoryStream(publicKey, writable: false); - return CryptographyHelper.PgpVerify(inputBytes, pubStream); + using var pubStream = verifier.OpenStream(); + return CryptographyHelper.PgpVerify(input, pubStream); } - - public bool PgpVerifyText(string input, byte[] publicKey) + public bool PgpVerifyText(string input, PgpPublicKey verifier) { ArgumentNullException.ThrowIfNull(input); - ThrowIfKeyMissing(publicKey, nameof(publicKey)); + ArgumentNullException.ThrowIfNull(verifier); - using var pubStream = new MemoryStream(publicKey, writable: false); + using var pubStream = verifier.OpenStream(); return CryptographyHelper.PgpVerifyText(input, pubStream); } - - public bool PgpVerifyFile(string inputFilePath, byte[] publicKey) + public bool PgpVerifyFile(string inputPath, PgpPublicKey verifier) { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - return PgpVerifyBytes(inputBytes, publicKey); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + return PgpVerifyBytes(File.ReadAllBytes(inputPath), verifier); } // ── PGP verify (clearsignature) ─────────────────────────────────────── - public bool PgpVerifyClearBytes(byte[] inputBytes, byte[] publicKey) + public bool PgpVerifyClearSignedBytes(byte[] input, PgpPublicKey verifier) { - ArgumentNullException.ThrowIfNull(inputBytes); - ThrowIfKeyMissing(publicKey, nameof(publicKey)); + ArgumentNullException.ThrowIfNull(input); + ArgumentNullException.ThrowIfNull(verifier); - using var pubStream = new MemoryStream(publicKey, writable: false); - return CryptographyHelper.PgpVerifyClear(inputBytes, pubStream); + using var pubStream = verifier.OpenStream(); + return CryptographyHelper.PgpVerifyClear(input, pubStream); } - - public bool PgpVerifyClearText(string input, byte[] publicKey) + public bool PgpVerifyClearSignedText(string input, PgpPublicKey verifier) { ArgumentNullException.ThrowIfNull(input); - ThrowIfKeyMissing(publicKey, nameof(publicKey)); + ArgumentNullException.ThrowIfNull(verifier); - using var pubStream = new MemoryStream(publicKey, writable: false); + using var pubStream = verifier.OpenStream(); return CryptographyHelper.PgpVerifyClearText(input, pubStream); } - - public bool PgpVerifyClearFile(string inputFilePath, byte[] publicKey) + public bool PgpVerifyClearSignedFile(string inputPath, PgpPublicKey verifier) { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - return PgpVerifyClearBytes(inputBytes, publicKey); + ThrowIfFilePathMissing(inputPath, nameof(inputPath)); + return PgpVerifyClearSignedBytes(File.ReadAllBytes(inputPath), verifier); } - // ── PGP verify (public key well-formedness) ─────────────────────────── + // ── PGP key generation + public-key verification ────────────────────── - public bool PgpVerifyPublicKeyBytes(byte[] publicKey) + public PgpKeyPair PgpGenerateKeys(string userId, string passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096) { - ThrowIfKeyMissing(publicKey, nameof(publicKey)); + if (string.IsNullOrWhiteSpace(userId)) + throw new ArgumentException("User ID must not be null or empty.", nameof(userId)); - using var pubStream = new MemoryStream(publicKey, writable: false); - return CryptographyHelper.PgpVerifyPublicKey(pubStream); + string pubPath = Path.Combine(Path.GetTempPath(), $"crypto_pgp_pub_{Guid.NewGuid():N}.asc"); + string privPath = Path.Combine(Path.GetTempPath(), $"crypto_pgp_priv_{Guid.NewGuid():N}.asc"); + try + { + CryptographyHelper.PgpGenerateKeys(pubPath, privPath, userId, passphrase, keySize); + byte[] pubBytes = File.ReadAllBytes(pubPath); + byte[] privBytes = File.ReadAllBytes(privPath); + return new PgpKeyPair(PgpPublicKey.FromBytes(pubBytes), PgpPrivateKey.FromBytes(privBytes, passphrase)); + } + finally + { + TryDelete(pubPath); + TryDelete(privPath); + } } - - public bool PgpVerifyPublicKeyText(string publicKey) + public PgpKeyPair PgpGenerateKeys(string userId, SecureString passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096) { - if (string.IsNullOrEmpty(publicKey)) - throw new ArgumentException("Public key must not be null or empty.", nameof(publicKey)); - - byte[] keyBytes = Encoding.UTF8.GetBytes(publicKey); - return PgpVerifyPublicKeyBytes(keyBytes); + ArgumentNullException.ThrowIfNull(passphrase); + return PgpGenerateKeys(userId, SecureStringToManagedString(passphrase), keySize); } - - public bool PgpVerifyPublicKeyFile(string publicKeyFilePath) + public bool PgpVerifyPublicKey(PgpPublicKey key) { - ThrowIfFilePathMissing(publicKeyFilePath, nameof(publicKeyFilePath)); - byte[] keyBytes = File.ReadAllBytes(publicKeyFilePath); - return PgpVerifyPublicKeyBytes(keyBytes); + ArgumentNullException.ThrowIfNull(key); + using var stream = key.OpenStream(); + return CryptographyHelper.PgpVerifyPublicKey(stream); } - // ── PGP key generation ─────────────────────────────────────────────── + // ── Private helpers ─────────────────────────────────────────────────── - public void PgpGenerateKeys(string publicKeyPath, string privateKeyPath, string userId, string passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096) + // Defence-in-depth runtime validation. The (key kind × wire format) pairing is already + // 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) { - ThrowIfFilePathMissing(publicKeyPath, nameof(publicKeyPath)); - ThrowIfFilePathMissing(privateKeyPath, nameof(privateKeyPath)); - - CryptographyHelper.PgpGenerateKeys(publicKeyPath, privateKeyPath, userId, passphrase, keySize); + 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); } - // ── Private helpers ─────────────────────────────────────────────────── - private static string ComputeHashHex(KeyedHashAlgorithms algorithm, byte[] inputBytes, byte[] keyBytes) { byte[] hashBytes = CryptographyHelper.HashDataWithKey(algorithm, inputBytes, keyBytes); return BitConverter.ToString(hashBytes).Replace("-", string.Empty); } - private static byte[] PgpSignBytesFromFile(string inputFilePath, byte[] privateKey, string passphrase, bool sign) - { - ThrowIfFilePathMissing(inputFilePath, nameof(inputFilePath)); - ThrowIfKeyMissing(privateKey, nameof(privateKey)); - - byte[] inputBytes = File.ReadAllBytes(inputFilePath); - using var privStream = new MemoryStream(privateKey, writable: false); - return sign - ? CryptographyHelper.PgpSign(inputBytes, privStream, passphrase) - : CryptographyHelper.PgpClearSign(inputBytes, privStream, passphrase); - } - - private static void ThrowIfKeyMissing(byte[] keyBytes, string paramName) - { - if (keyBytes is null || keyBytes.Length == 0) - throw new ArgumentException("Key bytes must not be null or empty.", paramName); - } - private static void ThrowIfFilePathMissing(string path, string paramName) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("File path must not be null or empty.", paramName); } - private static MemoryStream ToReadOnlyStream(byte[] bytes) => - bytes is { Length: > 0 } ? new MemoryStream(bytes, writable: false) : null; - - private static void WriteFile(string outputFilePath, byte[] bytes, bool overwrite = false) + private static void WriteFile(string outputPath, byte[] bytes, bool overwrite) { - ThrowIfFilePathMissing(outputFilePath, nameof(outputFilePath)); - if (!overwrite && File.Exists(outputFilePath)) - throw new InvalidOperationException($"Output file already exists: {outputFilePath}"); - File.WriteAllBytes(outputFilePath, bytes); + ThrowIfFilePathMissing(outputPath, nameof(outputPath)); + if (!overwrite && File.Exists(outputPath)) + throw new InvalidOperationException($"Output file already exists: {outputPath}"); + File.WriteAllBytes(outputPath, bytes); } - // Extracts the SecureString content via unmanaged memory, encodes it with the - // caller-supplied Encoding, then zeros both the unmanaged buffer and the char[] - // before returning. No managed string is ever created. - private static byte[] SecureStringToBytes(SecureString value, Encoding encoding) + private static void TryDelete(string path) { - IntPtr ptr = IntPtr.Zero; - char[] chars = null; - try - { - ptr = Marshal.SecureStringToGlobalAllocUnicode(value); - int length = value.Length; - chars = new char[length]; - Marshal.Copy(ptr, chars, 0, length); - return encoding.GetBytes(chars); - } - finally - { - if (ptr != IntPtr.Zero) - Marshal.ZeroFreeGlobalAllocUnicode(ptr); - if (chars != null) - Array.Clear(chars, 0, chars.Length); - } + try { if (File.Exists(path)) File.Delete(path); } + catch { /* best-effort cleanup */ } } - // Used only by PGP overloads: BouncyCastle requires a plain string passphrase - // and offers no byte[]-based API. The managed string cannot be zeroed afterward. private static string SecureStringToManagedString(SecureString value) { IntPtr ptr = IntPtr.Zero; diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/ICryptographyService.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/ICryptographyService.cs index 7215f0b05..f211957f2 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/ICryptographyService.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.API/Services/ICryptographyService.cs @@ -1,6 +1,4 @@ -using System.Net; using System.Security; -using System.Text; using UiPath.Cryptography.Enums; namespace UiPath.Cryptography.Activities.API @@ -15,295 +13,276 @@ namespace UiPath.Cryptography.Activities.API /// exposes three input/output forms so callers can use the one that matches the data they already have: /// /// - /// Bytes form — base method name. Bytes in, bytes out. The most flexible; binary or text data both work as byte[]. - /// Text form...Text suffix. String in, string out (Base64 for symmetric, ASCII-armored for PGP). Use when the data arrives as text from HTTP/config/env-vars. - /// File form...File suffix. File-path in, file-path out. Use when the data lives on disk. + /// Bytes form — base method name. Bytes in, bytes out. + /// Text form...Text suffix. String in, string out (Base64 for symmetric, ASCII-armored for PGP). + /// File form...File suffix. File-path in, file-path out. /// - /// IV / salt / nonce strategy (symmetric methods) + /// Symmetric wire format /// - /// All symmetric encrypt methods are non-deterministic: a fresh random salt (8 bytes, PBKDF2) and IV/nonce - /// are generated on every call and prepended to the ciphertext. Encrypting the same plaintext twice always - /// produces different ciphertext. The paired decrypt methods reconstruct the salt and IV from the same prefix, - /// so the output of Encrypt can always be fed directly to Decrypt without supplying the IV separately. - /// - /// - /// CBC-family algorithms (AES/Rijndael/DES/3DES/RC2) use PKCS7 padding, CBC mode, and a randomly-generated IV. - /// AES-GCM () uses a randomly-generated 96-bit nonce and a 128-bit - /// authentication tag, providing authenticated encryption with associated data (AEAD) — it is the recommended - /// choice for new workflows. + /// All symmetric methods default to — the byte-stable + /// UiPath layout (salt(8) || IV || ct [|| tag(16)], PBKDF2-HMAC-SHA1 @ 10 000 iterations). + /// Pass / to opt into + /// (modern KDF iterations), + /// (caller-supplied key + IV, third-party interop), or + /// (openssl enc-compatible). See + /// docs/symmetric-wire-format.md for the byte layouts. /// /// Key material /// - /// Every symmetric / keyed-hash method accepts a string key, SecureString key, or - /// byte[] keyBytes overload. Prefer the byte[] overload when key material is already loaded into - /// memory as bytes. The SecureString overload is supported for symmetric operations and keyed hashes: - /// the key is extracted via unmanaged memory, encoded to bytes, used, and then zeroed — no managed - /// is created. The plain-string overload remains for compatibility; note that - /// values are immutable and may be interned, meaning the secret can linger on the - /// heap until GC. + /// Symmetric methods take a / + /// object that carries the key together with the wire format. The format factories accept + /// either a (for the PBKDF2-based formats Classic, Owasp2026, + /// OpenSslEnc) or a (for ), so + /// mismatched (key kind × format) pairings fail at compile time. Construct keys via + /// / + /// / + /// / / + /// . /// - /// PGP keys /// - /// PGP methods accept the public/private key as a byte[]. Both ASCII-armored (text starting with - /// -----BEGIN PGP …-----) and binary OpenPGP encodings are supported — the underlying parser - /// auto-detects the form. For armored text that already lives in a (HTTP responses, - /// configuration files, environment variables), convert with Encoding.UTF8.GetBytes(armored) at the - /// call site. + /// Keyed-hash methods take a directly — they have no wire-format + /// axis so no options object is required. /// - /// PGP passphrase limitation + /// PGP keys /// - /// PGP overloads that accept SecureString passphrase must materialise the passphrase to a managed - /// because the underlying BouncyCastle library requires a plain string and offers no - /// byte[]-based passphrase API. The managed string cannot be zeroed afterward. For maximum security with PGP, - /// prefer passing a key ring that does not require a passphrase, or accept that the passphrase will briefly - /// exist as a managed string. + /// PGP methods take / handles. Construct + /// them from in-memory bytes or a file path via the factory methods. The private key carries + /// its own passphrase, bound at construction. Passing a to an encrypt + /// method implies signing; passing a to a decrypt method implies + /// signature verification — separate bool sign / bool verifySignature flags are + /// not used. /// /// public interface ICryptographyService { - // ── Symmetric encrypt ──────────────────────────────────────────────── - - /// Encrypts bytes using the specified algorithm and a string key. - byte[] EncryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, string key, Encoding encoding); - - /// Encrypts bytes using the specified algorithm and a key. - byte[] EncryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding); - - /// Encrypts bytes using the specified algorithm and raw key bytes. - byte[] EncryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, byte[] keyBytes); - - - /// Encrypts a string using the specified algorithm and key; returns Base64-encoded ciphertext. - string EncryptText(string input, EncryptionAlgorithm algorithm, string key, Encoding encoding); - - /// Encrypts a string using the specified algorithm and a key; returns Base64-encoded ciphertext. - string EncryptText(string input, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding); - - /// Encrypts a string using the specified algorithm and raw key bytes; returns Base64-encoded ciphertext. - string EncryptText(string input, EncryptionAlgorithm algorithm, byte[] keyBytes, Encoding encoding); - - - /// Encrypts a file and writes the result to the output path. - void EncryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, string key, Encoding encoding, bool overwrite = false); - - /// Encrypts a file and writes the result to the output path using a key. - void EncryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding, bool overwrite = false); - - /// Encrypts a file and writes the result to the output path using raw key bytes. - void EncryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, byte[] keyBytes, bool overwrite = false); - + // ── Symmetric ───────────────────────────────────────────────────────── - // ── Symmetric decrypt ──────────────────────────────────────────────── - - /// Decrypts bytes using the specified algorithm and a string key. - byte[] DecryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, string key, Encoding encoding); - - /// Decrypts bytes using the specified algorithm and a key. - byte[] DecryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding); - - /// Decrypts bytes using the specified algorithm and raw key bytes. - byte[] DecryptBytes(byte[] inputBytes, EncryptionAlgorithm algorithm, byte[] keyBytes); - - - /// Decrypts a Base64-encoded string using the specified algorithm and key. - string DecryptText(string input, EncryptionAlgorithm algorithm, string key, Encoding encoding); - - /// Decrypts a Base64-encoded string using the specified algorithm and a key. - string DecryptText(string input, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding); - - /// Decrypts a Base64-encoded string using the specified algorithm and raw key bytes. - string DecryptText(string input, EncryptionAlgorithm algorithm, byte[] keyBytes, Encoding encoding); + /// + /// Encrypts arbitrary bytes using the algorithm and wire format selected on + /// . Returns the ciphertext — exact byte layout depends on + /// the format (see ). + /// + byte[] EncryptBytes(byte[] input, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options); + /// + /// Decrypts ciphertext produced by . The wire format on + /// must match the format used at encrypt time; mismatches + /// surface as + /// (AEAD tag failure) or garbled output (CBC). + /// + byte[] DecryptBytes(byte[] input, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options); - /// Decrypts a file and writes the result to the output path. - void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, string key, Encoding encoding, bool overwrite = false); + /// + /// Encrypts a string and returns the ciphertext as Base64. The input string is always + /// transcoded to bytes via — there is no + /// encoding parameter. For non-UTF-8 text or for explicit encoding control, transcode + /// to bytes at the call site and use instead. + /// + string EncryptText(string input, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options); - /// Decrypts a file and writes the result to the output path using a key. - void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding, bool overwrite = false); + /// + /// Decrypts a Base64-encoded ciphertext and returns the plaintext as a UTF-8 string. + /// The plaintext bytes are always decoded via + /// — there is no encoding parameter. For non-UTF-8 text or for explicit encoding + /// control, use and decode at the call site. + /// + string DecryptText(string input, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options); - /// Decrypts a file and writes the result to the output path using raw key bytes. - void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, byte[] keyBytes, bool overwrite = false); + /// + /// Reads , encrypts the contents, and writes the + /// ciphertext to . Throws + /// if the output file exists and is false. + /// + void EncryptFile(string inputPath, string outputPath, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options, bool overwrite = false); + /// + /// Reads an encrypted file from , decrypts the contents, + /// and writes the plaintext to . Throws + /// if the output file exists and + /// is false. + /// + void DecryptFile(string inputPath, string outputPath, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options, bool overwrite = false); // ── Keyed hash ──────────────────────────────────────────────────────── - /// Computes a keyed hash of bytes and returns the hex-encoded result. - string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, string key, Encoding encoding); - - /// Computes a keyed hash of bytes using a key and returns the hex-encoded result. - string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding); - - /// Computes a keyed hash of bytes using raw key bytes and returns the hex-encoded result. - string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, byte[] keyBytes); - - - /// Computes a keyed hash of a string and returns the hex-encoded result. - string KeyedHashText(string input, KeyedHashAlgorithms algorithm, string key, Encoding encoding); - - /// Computes a keyed hash of a string using a key and returns the hex-encoded result. - string KeyedHashText(string input, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding); - - /// Computes a keyed hash of a string using raw key bytes and returns the hex-encoded result. - string KeyedHashText(string input, KeyedHashAlgorithms algorithm, byte[] keyBytes, Encoding encoding); - - - /// Computes a keyed hash of a file and returns the hex-encoded result. - string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, string key, Encoding encoding); - - /// Computes a keyed hash of a file using a key and returns the hex-encoded result. - string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding); - - /// Computes a keyed hash of a file using raw key bytes and returns the hex-encoded result. - string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, byte[] keyBytes); - - - // ── PGP encrypt ─────────────────────────────────────────────────────── - - /// Encrypts bytes using PGP with the provided public key. - byte[] PgpEncryptBytes(byte[] inputBytes, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false); - - /// Encrypts bytes using PGP with the provided public key and a passphrase. - byte[] PgpEncryptBytes(byte[] inputBytes, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false); - - - /// Encrypts a string using PGP with the provided public key; returns ASCII-armored ciphertext. - string PgpEncryptText(string input, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false); - - /// Encrypts a string using PGP with the provided public key and a passphrase; returns ASCII-armored ciphertext. - string PgpEncryptText(string input, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false); - - - /// Encrypts a file using PGP and writes the result to the output path. - void PgpEncryptFile(string inputFilePath, string outputFilePath, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false, bool overwrite = false); - - /// Encrypts a file using PGP with a passphrase and writes the result to the output path. - void PgpEncryptFile(string inputFilePath, string outputFilePath, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false, bool overwrite = false); - - - // ── PGP decrypt ─────────────────────────────────────────────────────── - - /// Decrypts PGP-encrypted bytes using the provided private key. - byte[] PgpDecryptBytes(byte[] inputBytes, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false); - - /// Decrypts PGP-encrypted bytes using the provided private key and a passphrase. - byte[] PgpDecryptBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false); - - - /// Decrypts a PGP-encrypted ASCII-armored string using the provided private key. - string PgpDecryptText(string input, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false); - - /// Decrypts a PGP-encrypted ASCII-armored string using the provided private key and a passphrase. - string PgpDecryptText(string input, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false); - - - /// Decrypts a PGP-encrypted file and writes the result to the output path. - void PgpDecryptFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false, bool overwrite = false); - - /// Decrypts a PGP-encrypted file with a passphrase and writes the result to the output path. - void PgpDecryptFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false, bool overwrite = false); - - - // ── PGP sign ────────────────────────────────────────────────────────── - - /// Signs bytes using PGP with the provided private key and returns the signed payload. - byte[] PgpSignBytes(byte[] inputBytes, byte[] privateKey, string passphrase); + /// + /// Computes a keyed hash (HMAC for the HMAC* algorithms; plain hash with the key + /// ignored for the SHA*/MD5 algorithms) over and returns + /// the digest as an uppercase hex string. + /// + string KeyedHashBytes(byte[] input, KeyedHashAlgorithms algorithm, CryptoKey key); - /// Signs bytes using PGP with the provided private key and a passphrase. - byte[] PgpSignBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase); + /// + /// Computes a keyed hash over the UTF-8 bytes of and returns + /// the digest as an uppercase hex string. + /// + string KeyedHashText(string input, KeyedHashAlgorithms algorithm, CryptoKey key); + /// + /// Reads the file at and computes a keyed hash over its + /// bytes, returning the digest as an uppercase hex string. + /// + string KeyedHashFile(string inputPath, KeyedHashAlgorithms algorithm, CryptoKey key); - /// Signs a string using PGP with the provided private key; returns the signed payload as ASCII-armored text. - string PgpSignText(string input, byte[] privateKey, string passphrase); + // ── PGP encrypt (signer is optional — when supplied, output is encrypted-and-signed) ─ - /// Signs a string using PGP with a passphrase; returns the signed payload as ASCII-armored text. - string PgpSignText(string input, byte[] privateKey, SecureString passphrase); + /// + /// PGP-encrypts for the holder of . + /// When is supplied, the output is also signed with that + /// private key — the recipient can verify by passing the matching public key as the + /// verifier on . + /// + byte[] PgpEncryptBytes(byte[] input, PgpPublicKey recipient, PgpPrivateKey signer = null); + /// + /// PGP-encrypts and returns ASCII-armored ciphertext + /// (the -----BEGIN PGP MESSAGE----- / -----END PGP MESSAGE----- envelope). + /// When is supplied, the output is also signed. + /// + string PgpEncryptText(string input, PgpPublicKey recipient, PgpPrivateKey signer = null); - /// Signs a file with PGP and writes the signed output to the destination path. - void PgpSignFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, bool overwrite = false); + /// + /// Reads , PGP-encrypts the contents for + /// , and writes the result to . + /// When is supplied, the output is also signed. + /// + void PgpEncryptFile(string inputPath, string outputPath, PgpPublicKey recipient, PgpPrivateKey signer = null, bool overwrite = false); - /// Signs a file with PGP using a passphrase and writes the signed output to the destination path. - void PgpSignFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, bool overwrite = false); + // ── PGP decrypt (verifier is optional — when supplied, signature is checked too) ───── + /// + /// Decrypts a PGP-encrypted payload using 's private key + /// (the passphrase is bound to the key at construction). When + /// is supplied, the embedded signature is verified against that public key; + /// verification failure throws . + /// + byte[] PgpDecryptBytes(byte[] input, PgpPrivateKey recipient, PgpPublicKey verifier = null); - // ── PGP clearsign ───────────────────────────────────────────────────── + /// + /// Decrypts an ASCII-armored PGP message using 's private + /// key. When is supplied, the embedded signature is verified. + /// + string PgpDecryptText(string input, PgpPrivateKey recipient, PgpPublicKey verifier = null); - /// Creates a PGP clear-text signature for the given bytes and returns the signed payload. - byte[] PgpClearsignBytes(byte[] inputBytes, byte[] privateKey, string passphrase); + /// + /// Reads a PGP-encrypted file at , decrypts it using + /// , and writes the plaintext to . + /// When is supplied, the embedded signature is verified. + /// + void PgpDecryptFile(string inputPath, string outputPath, PgpPrivateKey recipient, PgpPublicKey verifier = null, bool overwrite = false); - /// Creates a PGP clear-text signature using a passphrase. - byte[] PgpClearsignBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase); + // ── PGP sign (binary) ───────────────────────────────────────────────── + /// + /// Produces a PGP binary signature over using + /// . The returned payload combines the signature with the + /// signed content; verify with . + /// + byte[] PgpSignBytes(byte[] input, PgpPrivateKey signer); - /// Creates a PGP clear-text signature for the given string; returns the clearsigned ASCII-armored text. - string PgpClearsignText(string input, byte[] privateKey, string passphrase); + /// + /// Produces a PGP binary signature over the UTF-8 bytes of , + /// returned as ASCII-armored text. Verify with . + /// + string PgpSignText(string input, PgpPrivateKey signer); - /// Creates a PGP clear-text signature for the given string with a passphrase. - string PgpClearsignText(string input, byte[] privateKey, SecureString passphrase); + /// + /// Reads , produces a PGP binary signature, and writes + /// the signed payload to . Verify with + /// . + /// + void PgpSignFile(string inputPath, string outputPath, PgpPrivateKey signer, bool overwrite = false); + // ── PGP clear-sign (text) ───────────────────────────────────────────── - /// Creates a PGP clear-text signature of a file and writes the result to the destination path. - void PgpClearsignFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, bool overwrite = false); + /// + /// Produces a PGP clear-text signature over . The original + /// content remains human-readable, wrapped between + /// -----BEGIN PGP SIGNED MESSAGE----- and -----END PGP SIGNATURE-----; + /// verify with . + /// + byte[] PgpClearSignBytes(byte[] input, PgpPrivateKey signer); - /// Creates a PGP clear-text signature of a file using a passphrase and writes the result to the destination path. - void PgpClearsignFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, bool overwrite = false); + /// + /// Produces a PGP clear-text signature over and returns the + /// ASCII-armored clear-signed envelope. Verify with . + /// + string PgpClearSignText(string input, PgpPrivateKey signer); + /// + /// Reads (treated as text), produces a PGP clear-text + /// signature, and writes the signed envelope to . Verify + /// with . + /// + void PgpClearSignFile(string inputPath, string outputPath, PgpPrivateKey signer, bool overwrite = false); // ── PGP verify (binary signature) ───────────────────────────────────── - /// Verifies a PGP binary signature against the provided public key. - bool PgpVerifyBytes(byte[] inputBytes, byte[] publicKey); - - - /// Verifies a PGP ASCII-armored signed string against the provided public key. - bool PgpVerifyText(string input, byte[] publicKey); - + /// + /// Verifies a PGP binary signature against . Returns + /// true when the signature is valid and the public key matches the signer; + /// returns false on signature mismatch or tampered content. + /// + bool PgpVerifyBytes(byte[] input, PgpPublicKey verifier); - /// Verifies a PGP-signed file against the provided public key. - bool PgpVerifyFile(string inputFilePath, byte[] publicKey); + /// + /// Verifies an ASCII-armored PGP binary signature against . + /// Returns true when valid; false on mismatch. + /// + bool PgpVerifyText(string input, PgpPublicKey verifier); + /// + /// Reads a PGP-signed file at and verifies the binary + /// signature against . Returns true when valid. + /// + bool PgpVerifyFile(string inputPath, PgpPublicKey verifier); // ── PGP verify (clearsignature) ─────────────────────────────────────── - /// Verifies a PGP clear-text signature against the provided public key. - bool PgpVerifyClearBytes(byte[] inputBytes, byte[] publicKey); - - - /// Verifies a PGP clearsigned ASCII-armored string against the provided public key. - bool PgpVerifyClearText(string input, byte[] publicKey); - - - /// Verifies a PGP clearsigned file against the provided public key. - bool PgpVerifyClearFile(string inputFilePath, byte[] publicKey); - - - // ── PGP verify (public key well-formedness) ─────────────────────────── - /// - /// Verifies that the supplied bytes contain a well-formed OpenPGP public key. - /// Mirrors the PgpVerify activity's Mode = PublicKey. + /// Verifies a PGP clear-text signature against . Returns + /// true when the embedded signature is valid; false on mismatch or + /// tampered content inside the clear-signed envelope. /// - bool PgpVerifyPublicKeyBytes(byte[] publicKey); + bool PgpVerifyClearSignedBytes(byte[] input, PgpPublicKey verifier); + /// + /// Verifies a PGP clear-text signature carried in (ASCII + /// armored) against . Returns true when valid. + /// + bool PgpVerifyClearSignedText(string input, PgpPublicKey verifier); - /// Verifies that the supplied ASCII-armored string is a well-formed OpenPGP public key. - bool PgpVerifyPublicKeyText(string publicKey); - + /// + /// Reads a clear-signed file at and verifies the + /// embedded signature against . Returns true when + /// valid. + /// + bool PgpVerifyClearSignedFile(string inputPath, PgpPublicKey verifier); - /// Verifies that the file at the supplied path is a well-formed OpenPGP public key. - bool PgpVerifyPublicKeyFile(string publicKeyFilePath); + // ── PGP key generation ──────────────────────────────────────────────── + /// + /// Generates an OpenPGP RSA key pair in memory. The public and private halves + /// returned in the are mathematically tied — call + /// / to persist. + /// + /// OpenPGP User ID, conventionally an RFC 2822 mailbox such as "Alice Doe <alice@example.com>". + /// Passphrase that will protect the generated private key. Bound to the returned . + /// RSA modulus size; defaults to 4096 bits. + PgpKeyPair PgpGenerateKeys(string userId, string passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096); - // ── PGP key pair generation ──────────────────────────────────────────────── + /// + /// Generates an OpenPGP RSA key pair in memory with a + /// passphrase. Same semantics as the string overload; see that method's docs + /// for the key-pair atomicity contract. + /// + PgpKeyPair PgpGenerateKeys(string userId, SecureString passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096); /// - /// Generates an OpenPGP RSA key pair and writes the keys to the specified paths. + /// Verifies that the supplied is a well-formed OpenPGP public key. + /// Returns true when parsing succeeds; false when the bytes are + /// malformed or not an OpenPGP key. /// - /// Path where the ASCII-armored public key will be written. - /// Path where the ASCII-armored private key will be written. - /// OpenPGP User ID; conventionally an RFC 2822 mailbox such as Alice Doe <alice@example.com>. - /// Passphrase that protects the generated private key. - /// RSA key size. Defaults to 4096-bit; 3072 and 2048 are accepted for interop with legacy systems. - void PgpGenerateKeys(string publicKeyPath, string privateKeyPath, string userId, string passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096); + bool PgpVerifyPublicKey(PgpPublicKey key); } } diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/DecryptFile.md b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/DecryptFile.md index 82347171c..50ac265c0 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/DecryptFile.md +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/DecryptFile.md @@ -18,6 +18,9 @@ Decrypts a file using a symmetric algorithm and key, or using PGP with a private | `Key` | Key | InArgument | `string` | Conditional | | The key used to decrypt the file. Provide either `Key` or `KeySecureString`. Symmetric algorithms only. | | `KeySecureString` | Key secure string | InArgument | `SecureString` | Conditional | | Secure-string variant of the key. Provide either `Key` or `KeySecureString`. Symmetric algorithms only. | | `KeyEncoding` | Key encoding | InArgument | `Encoding` | | | The encoding used to interpret the key. Symmetric algorithms only. | +| `Format` | Wire format | Property | `SymmetricWireFormat` | | `Classic` | The symmetric ciphertext layout to decrypt. Must match the format used at encrypt time. Symmetric algorithms only. | +| `KeyFormat` | Key bytes format | Property | `KeyBytesFormat` | | `Encoded` | How the `Key` string is interpreted. `Hex` or `Base64` are required when `Format = Raw`. Symmetric algorithms only. | +| `KdfIterations` | KDF iterations | InArgument | `int` | | `0` | PBKDF2 iteration count. Must match the value used at encrypt time. `0` uses the format's OWASP-recommended default. Rejected for `Classic` and `Raw`. | | `OutputFilePath` | Output file path | InArgument | `string` | | | The full path where the decrypted file will be saved. When empty, the file is written next to the input file using the name `_Decrypted`. | | `OutputFileName` | Decrypted file name | InArgument | `string` | | | The file name to use for the decrypted file. Honored when `OutputFilePath` is empty. | | `PrivateKeyFilePath` | Private key file path | InArgument | `string` | Conditional | | Path to your PGP private key file. Required when `Algorithm = PGP`. | @@ -40,22 +43,29 @@ The activity has two modes selected by `Algorithm`: **Symmetric mode** (`AESGCM`, `ChaCha20Poly1305`, `AES`, `TripleDES`, `DES`, `RC2`, `Rijndael`): - Provide `InputFilePath`, `Algorithm`, and exactly one of `Key` / `KeySecureString`. - `KeyEncoding` defaults to UTF-8. +- `Format` defaults to `Classic` — must match what was used at encrypt time. The IV (when present) is read from the ciphertext stream prefix automatically. +- For `Owasp2026` and `OpenSslEnc`: set `KdfIterations` to the same value used at encrypt time. +- For `Raw`: set `KeyFormat = Hex` or `Base64` and supply the same raw key bytes used at encrypt time. - PGP properties (private/public key, passphrase, `VerifySignature`) are ignored. **PGP mode** (`Algorithm = PGP`): - Provide `InputFilePath`, `PrivateKeyFilePath`, and exactly one of `Passphrase` / `PassphraseSecureString`. - Set `VerifySignature = True` and provide `PublicKeyFilePath` to additionally verify the signature embedded in the encrypted payload. -- Symmetric properties (`Key`, `KeySecureString`, `KeyEncoding`) are ignored. +- Symmetric properties (`Key`, `KeySecureString`, `KeyEncoding`, `Format`, `KeyFormat`, `KdfIterations`) are ignored. -The symmetric ciphertext format produced by `EncryptFile` is UiPath-specific (`salt(8) || IV || ciphertext [|| tag]`, PBKDF2-HMAC-SHA1 @ 10 000 iterations). See `docs/symmetric-wire-format.md` for the layout — ciphertext produced by other tools is not directly compatible. +The default symmetric format (`Classic`) is UiPath-specific (`salt(8) || IV || ciphertext [|| tag]`, PBKDF2-HMAC-SHA1 @ 10 000 iterations). Use `Raw` or `OpenSslEnc` to decrypt ciphertext produced by `openssl enc`, Java `javax.crypto`, Python `cryptography`, browser tools, etc. See `docs/symmetric-wire-format.md` for byte layouts and decoder examples. ### Enum Reference **`EncryptionAlgorithm`**: `AESGCM`, `ChaCha20Poly1305`, `PGP`, `AES` *(deprecated)*, `DES` *(deprecated)*, `RC2` *(deprecated)*, `Rijndael` *(deprecated)*, `TripleDES` *(deprecated)*. +**`SymmetricWireFormat`**: `Classic` (default), `Owasp2026`, `Raw`, `OpenSslEnc`. + +**`KeyBytesFormat`**: `Encoded` (default — string is a password), `Hex`, `Base64`. The activity's dropdown only surfaces `Hex` / `Base64` because `Encoded` is the implicit non-Raw choice. + ## XAML Example -Symmetric decrypt (AES-GCM): +Symmetric decrypt — Classic (default): ```xml ``` +Symmetric decrypt — `OpenSslEnc` (input produced by `openssl enc -pbkdf2 -iter 600000 -md sha256 -salt -k password`): + +```xml + +``` + PGP decrypt with signature verification: ```xml diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/DecryptText.md b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/DecryptText.md index 533a97ac3..15a8962ed 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/DecryptText.md +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/DecryptText.md @@ -18,6 +18,9 @@ Decrypts a text string using a symmetric algorithm, or using PGP with a private | `Key` | Key | InArgument | `string` | Conditional | | The key used to decrypt the input. Provide either `Key` or `KeySecureString`. Symmetric algorithms only. | | `KeySecureString` | Key secure string | InArgument | `SecureString` | Conditional | | Secure-string variant of the key. Symmetric algorithms only. | | `Encoding` | Encoding | InArgument | `Encoding` | | | The encoding used to interpret the input text and the key. Symmetric algorithms only. | +| `Format` | Wire format | Property | `SymmetricWireFormat` | | `Classic` | The symmetric ciphertext layout to decrypt. Must match the format used at encrypt time. Symmetric algorithms only. | +| `KeyFormat` | Key bytes format | Property | `KeyBytesFormat` | | `Encoded` | How the `Key` string is interpreted. `Hex` or `Base64` are required when `Format = Raw`. Symmetric algorithms only. | +| `KdfIterations` | KDF iterations | InArgument | `int` | | `0` | PBKDF2 iteration count. Must match the value used at encrypt time. `0` uses the format's OWASP-recommended default. Rejected for `Classic` and `Raw`. | | `PrivateKeyFilePath` | Private key file path | InArgument | `string` | Conditional | | Path to your PGP private key file. Required when `Algorithm = PGP`. | | `Passphrase` | Passphrase | InArgument | `string` | Conditional | | Passphrase that unlocks the private key. Provide either `Passphrase` or `PassphraseSecureString`. PGP only. | | `PassphraseSecureString` | Passphrase (secure) | InArgument | `SecureString` | Conditional | | Secure-string variant of the passphrase. PGP only. | @@ -43,22 +46,28 @@ The activity has two modes selected by `Algorithm`: **Symmetric mode** (`AESGCM`, `ChaCha20Poly1305`, `AES`, `TripleDES`, `DES`, `RC2`, `Rijndael`): - Provide `Input`, `Algorithm`, and exactly one of `Key` / `KeySecureString`. - `Encoding` defaults to UTF-8. -- Input must be the Base64 string produced by `EncryptText`. +- `Format` defaults to `Classic` — must match what was used at encrypt time. The IV (when present) is read from the ciphertext stream prefix automatically. +- For `Owasp2026` and `OpenSslEnc`: set `KdfIterations` to the same value used at encrypt time (the iteration count is **not** carried in the wire format). +- For `Raw`: set `KeyFormat = Hex` or `Base64` and supply the same raw key bytes used at encrypt time. **PGP mode** (`Algorithm = PGP`): - Provide `Input`, `PrivateKeyFilePath`, and exactly one of `Passphrase` / `PassphraseSecureString`. - Set `VerifySignature = True` and provide `PublicKeyFilePath` to additionally verify the embedded signature. -- Symmetric properties (`Key`, `KeySecureString`, `Encoding`) are ignored. +- Symmetric properties (`Key`, `KeySecureString`, `Encoding`, `Format`, `KeyFormat`, `KdfIterations`) are ignored. -The symmetric format is UiPath-specific (`salt(8) || IV || ciphertext [|| tag]`, PBKDF2-HMAC-SHA1 @ 10 000 iterations) — see `docs/symmetric-wire-format.md`. +The default symmetric format (`Classic`) is UiPath-specific (`salt(8) || IV || ciphertext [|| tag]`, PBKDF2-HMAC-SHA1 @ 10 000 iterations). Use `Raw` or `OpenSslEnc` to decrypt ciphertext produced by `openssl enc`, Java `javax.crypto`, Python `cryptography`, browser tools, etc. See `docs/symmetric-wire-format.md` for byte layouts and decoder examples. ### Enum Reference **`EncryptionAlgorithm`**: `AESGCM`, `ChaCha20Poly1305`, `PGP`, `AES` *(deprecated)*, `DES` *(deprecated)*, `RC2` *(deprecated)*, `Rijndael` *(deprecated)*, `TripleDES` *(deprecated)*. +**`SymmetricWireFormat`**: `Classic` (default), `Owasp2026`, `Raw`, `OpenSslEnc`. + +**`KeyBytesFormat`**: `Encoded` (default — string is a password), `Hex`, `Base64`. The activity's dropdown only surfaces `Hex` / `Base64` because `Encoded` is the implicit non-Raw choice. + ## XAML Example -Symmetric decrypt (AES-GCM): +Symmetric decrypt — Classic (default): ```xml ``` +Symmetric decrypt — `Raw` with caller-supplied key: + +```xml + +``` + +Symmetric decrypt — `OpenSslEnc` (input produced by `openssl enc -pbkdf2 -iter 600000 -md sha256 -salt -k password`): + +```xml + +``` + PGP decrypt: ```xml diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/EncryptFile.md b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/EncryptFile.md index e36012981..791c57dc4 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/EncryptFile.md +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/EncryptFile.md @@ -18,6 +18,10 @@ Encrypts a file using a symmetric algorithm and key, or using PGP with a recipie | `Key` | Key | InArgument | `string` | Conditional | | The key used to encrypt the file. Provide either `Key` or `KeySecureString`. Symmetric algorithms only. | | `KeySecureString` | Key secure string | InArgument | `SecureString` | Conditional | | Secure-string variant of the key. Symmetric algorithms only. | | `KeyEncoding` | Key encoding | InArgument | `Encoding` | | | The encoding used to interpret the key. Symmetric algorithms only. | +| `Format` | Wire format | Property | `SymmetricWireFormat` | | `Classic` | The symmetric ciphertext layout. `Classic` (default) is UiPath's byte-stable layout; `Owasp2026` uses the same layout with stronger KDF iterations; `Raw` is caller-supplied key + IV for third-party interop; `OpenSslEnc` produces `openssl enc`-compatible output. Symmetric algorithms only. | +| `KeyFormat` | Key bytes format | Property | `KeyBytesFormat` | | `Encoded` | How the `Key` string is interpreted. `Hex` or `Base64` are required when `Format = Raw`. Symmetric algorithms only. | +| `Iv` | IV | InArgument | `string` | Conditional | | Initialization vector when `Format = Raw`. Interpreted per `KeyFormat`. Optional — leave empty to let the cipher generate one. Rejected for all other formats. | +| `KdfIterations` | KDF iterations | InArgument | `int` | | `0` | PBKDF2 iteration count. `0` uses the format's OWASP-recommended default (1 300 000 for `Owasp2026`, 600 000 for `OpenSslEnc`). Rejected for `Classic` and `Raw`. | | `OutputFilePath` | Output file path | InArgument | `string` | | | The full path where the encrypted file will be saved. When empty, the file is written next to the input file using the name `_Encrypted`. | | `OutputFileName` | Encrypted file name | InArgument | `string` | | | The file name to use for the encrypted file. Honored when `OutputFilePath` is empty. | | `PublicKeyFilePath` | Public key file path | InArgument | `string` | Conditional | | Path to the recipient's PGP public key file. Required when `Algorithm = PGP`. | @@ -40,24 +44,31 @@ The activity has two modes selected by `Algorithm`: **Symmetric mode** (`AESGCM`, `ChaCha20Poly1305`, `AES`, `TripleDES`, `DES`, `RC2`, `Rijndael`): - Provide `InputFilePath`, `Algorithm`, and exactly one of `Key` / `KeySecureString`. - `KeyEncoding` defaults to UTF-8. +- `Format` defaults to `Classic` — existing workflows that omit this property produce the same byte-stable output as before. +- For `Owasp2026` or `OpenSslEnc`: `KeyFormat` stays `Encoded`; optionally set `KdfIterations`. +- For `Raw`: set `KeyFormat = Hex` or `Base64` and supply a literal cipher key. `Iv` is optional. `KdfIterations` is rejected. - PGP properties (`PublicKeyFilePath`, `PrivateKeyFilePath`, `Passphrase`, `SignData`) are ignored. **PGP encrypt only** (`Algorithm = PGP`, `SignData = False`): - Provide `InputFilePath` and `PublicKeyFilePath` (recipient). -- Symmetric properties are ignored. +- Symmetric properties (including `Format`, `KeyFormat`, `Iv`, `KdfIterations`) are ignored. **PGP encrypt + sign** (`Algorithm = PGP`, `SignData = True`): - Provide `InputFilePath`, `PublicKeyFilePath`, `PrivateKeyFilePath`, and exactly one of `Passphrase` / `PassphraseSecureString`. -The symmetric ciphertext format produced by this activity is UiPath-specific (`salt(8) || IV || ciphertext [|| tag]`, PBKDF2-HMAC-SHA1 @ 10 000 iterations). See `docs/symmetric-wire-format.md` — it is not directly compatible with `openssl enc` or other standard tools. +The default symmetric format (`Classic`) is UiPath-specific (`salt(8) || IV || ciphertext [|| tag]`, PBKDF2-HMAC-SHA1 @ 10 000 iterations). Use `Raw` or `OpenSslEnc` for interop with `openssl enc`, Java `javax.crypto`, Python `cryptography`, browser tools, etc. See `docs/symmetric-wire-format.md` for byte layouts, decoder examples, and the per-format validation matrix. ### Enum Reference **`EncryptionAlgorithm`**: `AESGCM`, `ChaCha20Poly1305`, `PGP`, `AES` *(deprecated)*, `DES` *(deprecated)*, `RC2` *(deprecated)*, `Rijndael` *(deprecated)*, `TripleDES` *(deprecated)*. +**`SymmetricWireFormat`**: `Classic` (default), `Owasp2026`, `Raw`, `OpenSslEnc`. + +**`KeyBytesFormat`**: `Encoded` (default — string is a password), `Hex`, `Base64`. The activity's dropdown only surfaces `Hex` / `Base64` because `Encoded` is the implicit non-Raw choice. + ## XAML Example -Symmetric encrypt (AES-GCM): +Symmetric encrypt — Classic (default): ```xml ``` +Symmetric encrypt — `OpenSslEnc` (decryptable by `openssl enc -d -pbkdf2 -iter 600000 -md sha256`): + +```xml + +``` + PGP encrypt and sign: ```xml diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/EncryptText.md b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/EncryptText.md index fb96bc7ee..ed773d5ce 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/EncryptText.md +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/EncryptText.md @@ -18,6 +18,10 @@ Encrypts a text string using a symmetric algorithm, or using PGP with a recipien | `Key` | Key | InArgument | `string` | Conditional | | The key used to encrypt the input. Provide either `Key` or `KeySecureString`. Symmetric algorithms only. | | `KeySecureString` | Key secure string | InArgument | `SecureString` | Conditional | | Secure-string variant of the key. Symmetric algorithms only. | | `Encoding` | Encoding | InArgument | `Encoding` | | | The encoding used to interpret the input text and the key. Symmetric algorithms only. | +| `Format` | Wire format | Property | `SymmetricWireFormat` | | `Classic` | The symmetric ciphertext layout. `Classic` (default) is UiPath's byte-stable layout; `Owasp2026` uses the same layout with stronger KDF iterations; `Raw` is caller-supplied key + IV for third-party interop; `OpenSslEnc` produces `openssl enc`-compatible output. Symmetric algorithms only. | +| `KeyFormat` | Key bytes format | Property | `KeyBytesFormat` | | `Encoded` | How the `Key` string is interpreted. `Hex` or `Base64` are required when `Format = Raw`; otherwise the key is treated as a password. Symmetric algorithms only. | +| `Iv` | IV | InArgument | `string` | Conditional | | Initialization vector when `Format = Raw`. Interpreted per `KeyFormat`. Optional — leave empty to let the cipher generate one. Rejected for all other formats. | +| `KdfIterations` | KDF iterations | InArgument | `int` | | `0` | PBKDF2 iteration count. `0` uses the format's OWASP-recommended default (1 300 000 for `Owasp2026`, 600 000 for `OpenSslEnc`). Rejected for `Classic` and `Raw`. | | `PublicKeyFilePath` | Public key file path | InArgument | `string` | Conditional | | Path to the recipient's PGP public key file. Required when `Algorithm = PGP`. | | `PrivateKeyFilePath` | Private key file path | InArgument | `string` | Conditional | | Path to your PGP private key file. Required only when `SignData = True`. | | `Passphrase` | Passphrase | InArgument | `string` | Conditional | | Passphrase that unlocks the private key (signing). Provide either `Passphrase` or `PassphraseSecureString`. PGP-sign only. | @@ -41,22 +45,29 @@ Encrypts a text string using a symmetric algorithm, or using PGP with a recipien **Symmetric mode** (`AESGCM`, `ChaCha20Poly1305`, `AES`, `TripleDES`, `DES`, `RC2`, `Rijndael`): - Provide `Input`, `Algorithm`, and exactly one of `Key` / `KeySecureString`. - `Encoding` defaults to UTF-8. +- `Format` defaults to `Classic` — existing workflows that omit this property produce the same byte-stable output as before. +- For `Owasp2026` or `OpenSslEnc`: `KeyFormat` stays `Encoded`; optionally set `KdfIterations`. +- For `Raw`: set `KeyFormat = Hex` or `Base64` and supply a literal cipher key. `Iv` is optional. `KdfIterations` is rejected. **PGP encrypt only** (`Algorithm = PGP`, `SignData = False`): -- Provide `Input` and `PublicKeyFilePath` (recipient). +- Provide `Input` and `PublicKeyFilePath` (recipient). Symmetric `Format` / `KeyFormat` / `Iv` / `KdfIterations` are ignored. **PGP encrypt + sign** (`Algorithm = PGP`, `SignData = True`): - Provide `Input`, `PublicKeyFilePath`, `PrivateKeyFilePath`, and exactly one of `Passphrase` / `PassphraseSecureString`. -The symmetric format is UiPath-specific (`salt(8) || IV || ciphertext [|| tag]`, PBKDF2-HMAC-SHA1 @ 10 000 iterations) — see `docs/symmetric-wire-format.md`. +The default symmetric format (`Classic`) is UiPath-specific (`salt(8) || IV || ciphertext [|| tag]`, PBKDF2-HMAC-SHA1 @ 10 000 iterations). Use `Raw` or `OpenSslEnc` for interop with `openssl enc`, Java `javax.crypto`, Python `cryptography`, browser tools, etc. See `docs/symmetric-wire-format.md` for byte layouts, decoder examples, and the per-format validation matrix. ### Enum Reference **`EncryptionAlgorithm`**: `AESGCM`, `ChaCha20Poly1305`, `PGP`, `AES` *(deprecated)*, `DES` *(deprecated)*, `RC2` *(deprecated)*, `Rijndael` *(deprecated)*, `TripleDES` *(deprecated)*. +**`SymmetricWireFormat`**: `Classic` (default), `Owasp2026`, `Raw`, `OpenSslEnc`. + +**`KeyBytesFormat`**: `Encoded` (default — string is a password), `Hex`, `Base64`. The activity's dropdown only surfaces `Hex` / `Base64` because `Encoded` is the implicit non-Raw choice. + ## XAML Example -Symmetric encrypt (AES-GCM): +Symmetric encrypt — Classic (default): ```xml ``` +Symmetric encrypt — `Raw` with caller-supplied key (third-party interop): + +```xml + +``` + +Symmetric encrypt — `OpenSslEnc` (decryptable by `openssl enc -d -pbkdf2 -iter 600000 -md sha256`): + +```xml + +``` + PGP encrypt and sign: ```xml diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/KeyedHashFile.md b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/KeyedHashFile.md index 99da3b208..dfd8c1517 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/KeyedHashFile.md +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/KeyedHashFile.md @@ -28,7 +28,7 @@ Hashes a file using the specified algorithm and returns the hexadecimal hash str | Name | Display Name | Kind | Type | Description | |------|-------------|------|------|-------------| -| `Result` | Hash | OutArgument | `string` | The hash, as a lower-case hexadecimal string. | +| `Result` | Hash | OutArgument | `string` | The hash, as an upper-case hexadecimal string. | ## Valid Configurations diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/KeyedHashText.md b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/KeyedHashText.md index 05be253f2..49339f0f2 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/KeyedHashText.md +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/KeyedHashText.md @@ -28,7 +28,7 @@ Hashes a text string using the specified algorithm and returns the hexadecimal h | Name | Display Name | Kind | Type | Description | |------|-------------|------|------|-------------| -| `Result` | Hash | OutArgument | `string` | The hash, as a lower-case hexadecimal string. | +| `Result` | Hash | OutArgument | `string` | The hash, as an upper-case hexadecimal string. | ## Valid Configurations diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/PgpClearsignFile.md b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/PgpClearSignFile.md similarity index 95% rename from Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/PgpClearsignFile.md rename to Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/PgpClearSignFile.md index 0e51588b0..5d3e20556 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/PgpClearsignFile.md +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/activities/PgpClearSignFile.md @@ -1,6 +1,6 @@ -# PGP Clearsign File +# PGP ClearSign File -`UiPath.Cryptography.Activities.PgpClearsignFile` +`UiPath.Cryptography.Activities.PgpClearSignFile` Creates a PGP clear-text signature of a file using a private key. The clearsigned output contains the original plaintext wrapped between `-----BEGIN PGP SIGNED MESSAGE-----` and `-----END PGP SIGNATURE-----` markers, so it is human-readable and tamper-evident. @@ -34,7 +34,7 @@ Creates a PGP clear-text signature of a file using a private key. The clearsigne ## XAML Example ```xml -(...)` | +| `KeyedHashBytes(input, algo, string key, Encoding enc)` | `KeyedHashBytes(input, algo, PasswordKey.FromPassword(key, enc))` | +| `KeyedHashBytes(input, algo, byte[] keyBytes)` | `KeyedHashBytes(input, algo, RawKey.FromBytes(keyBytes))` | +| `PgpGenerateKeys(publicKeyPath, privateKeyPath, userId, passphrase, keySize)` *(path-based)* | `var pair = PgpGenerateKeys(userId, passphrase, keySize); pair.PublicKey.Save(publicKeyPath); pair.PrivateKey.Save(privateKeyPath);` | -| Overload | Signature | -|----------|-----------| -| String key | `string DecryptText(string input, EncryptionAlgorithm algorithm, string key, Encoding encoding)` | -| SecureString key | `string DecryptText(string input, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding)` | -| Raw bytes | `string DecryptText(string input, EncryptionAlgorithm algorithm, byte[] keyBytes, Encoding encoding)` | +### Plaintext encoding for `EncryptText` / `DecryptText` -**Returns:** `string` — original plaintext. +The old text APIs took the plaintext `Encoding` as a positional parameter. In the new shape, **the same encoding is carried on the options object** via the optional trailing `encoding:` parameter on every format factory, and defaults to `Encoding.UTF8` when omitted: ---- +```csharp +// Old +cryptography.EncryptText(input, algo, key, Encoding.Latin1); -### `void DecryptFile(...)` +// New +var pwKey = PasswordKey.FromPassword(key, Encoding.UTF8); // password bytes encoding +cryptography.EncryptText(input, algo, SymmetricEncryptOptions.Classic(pwKey, Encoding.Latin1)); +// plaintext encoding ↑ +``` -Reads an encrypted file produced by `EncryptFile` and writes the plaintext to an output path. +The encoding on the options is ignored by `EncryptBytes` / `DecryptBytes` / `EncryptFile` / `DecryptFile` — for those, transcode at the call site if needed. -| Overload | Signature | -|----------|-----------| -| String key | `void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, string key, Encoding encoding, bool overwrite = false)` | -| SecureString key | `void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, SecureString key, Encoding encoding, bool overwrite = false)` | -| Raw bytes | `void DecryptFile(string inputFilePath, string outputFilePath, EncryptionAlgorithm algorithm, byte[] keyBytes, bool overwrite = false)` | +### Behaviours to be aware of -**Returns:** `void` — output file is written to `outputFilePath`. +- **Default text encoding is UTF-8.** Code that didn't pass an encoding to the old text APIs will continue to behave identically if the old call also used UTF-8. Calls that relied on the prior default of UTF-8 need no migration changes beyond the options refactor. +- **No silent compile-by-renaming.** Because every key/options type is new, code referencing the old overloads fails to compile rather than silently picking up a different overload. The mapping above gives the one-to-one replacement for each. +- **Path-based `PgpGenerateKeys` returns a key pair, not files.** The old overload wrote files as a side effect. The new overload returns an in-memory `PgpKeyPair`; call `.Save(path)` on each half to persist. --- -## Keyed Hashing - -Keyed hash methods compute an HMAC (or plain hash for non-HMAC algorithms) and return the result as a lowercase hex string. One-way — no corresponding "unhash" operation. - -### `string KeyedHashBytes(...)` - -| Overload | Signature | -|----------|-----------| -| String key | `string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, string key, Encoding encoding)` | -| SecureString key | `string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding)` | -| Raw bytes | `string KeyedHashBytes(byte[] inputBytes, KeyedHashAlgorithms algorithm, byte[] keyBytes)` | - -**Returns:** `string` — lowercase hex-encoded hash digest. +## Symmetric Encryption ---- +### `byte[] EncryptBytes(byte[] input, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options)` -### `string KeyedHashText(...)` +Encrypts arbitrary bytes. The `options` parameter carries the key + wire format; construct via a `SymmetricEncryptOptions.(key, ...)` factory. -| Overload | Signature | -|----------|-----------| -| String key | `string KeyedHashText(string input, KeyedHashAlgorithms algorithm, string key, Encoding encoding)` | -| SecureString key | `string KeyedHashText(string input, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding)` | -| Raw bytes | `string KeyedHashText(string input, KeyedHashAlgorithms algorithm, byte[] keyBytes, Encoding encoding)` | +**Returns:** `byte[]` — ciphertext per the chosen wire format. -**Returns:** `string` — lowercase hex-encoded hash digest. +### `string EncryptText(string input, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options)` ---- +Encrypts a string and returns the result as Base64-encoded ciphertext. The plaintext encoding is read from `options.TextEncoding` — defaulting to UTF-8 when the factory's optional `encoding:` parameter is omitted. Pass a different encoding to the format factory (`Classic(key, Encoding.Latin1)`, etc.) when migrating ciphertext produced by non-UTF-8 callers of the prior API. -### `string KeyedHashFile(...)` +**Returns:** `string` — Base64-encoded ciphertext. -| Overload | Signature | -|----------|-----------| -| String key | `string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, string key, Encoding encoding)` | -| SecureString key | `string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, SecureString key, Encoding encoding)` | -| Raw bytes | `string KeyedHashFile(string filePath, KeyedHashAlgorithms algorithm, byte[] keyBytes)` | +### `void EncryptFile(string inputPath, string outputPath, EncryptionAlgorithm algorithm, SymmetricEncryptOptions options, bool overwrite = false)` -**Returns:** `string` — lowercase hex-encoded hash digest. +Reads a file, encrypts it, and writes the result. Throws `InvalidOperationException` if `outputPath` exists and `overwrite` is false. --- -## PGP Encryption - -PGP encrypt methods accept the recipient's public key as `byte[]` (armored or binary). When `sign: true`, the sender's private key and passphrase are also required so the encrypted payload is signed. - -### `byte[] PgpEncryptBytes(...)` - -| Overload | Signature | -|----------|-----------| -| String passphrase | `byte[] PgpEncryptBytes(byte[] inputBytes, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false)` | -| SecureString passphrase | `byte[] PgpEncryptBytes(byte[] inputBytes, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false)` | - -**Returns:** `byte[]` — PGP-encrypted (and optionally signed) payload. - ---- +## Symmetric Decryption -### `string PgpEncryptText(...)` +### `byte[] DecryptBytes(byte[] input, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options)` -| Overload | Signature | -|----------|-----------| -| String passphrase | `string PgpEncryptText(string input, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false)` | -| SecureString passphrase | `string PgpEncryptText(string input, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false)` | +Decrypts ciphertext produced by `EncryptBytes`. `options.Format` must match the format used at encrypt time. -**Returns:** `string` — ASCII-armored PGP ciphertext. +**Returns:** `byte[]` — plaintext bytes. ---- +### `string DecryptText(string input, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options)` -### `void PgpEncryptFile(...)` +Decrypts a Base64-encoded ciphertext produced by `EncryptText` and returns the plaintext. The plaintext encoding is read from `options.TextEncoding` — defaulting to UTF-8 when the factory's optional `encoding:` parameter is omitted, and must match the encoding used at encrypt time. -| Overload | Signature | -|----------|-----------| -| String passphrase | `void PgpEncryptFile(string inputFilePath, string outputFilePath, byte[] publicKey, byte[] privateKey = null, string passphrase = null, bool sign = false, bool overwrite = false)` | -| SecureString passphrase | `void PgpEncryptFile(string inputFilePath, string outputFilePath, byte[] publicKey, byte[] privateKey, SecureString passphrase, bool sign = false, bool overwrite = false)` | +### `void DecryptFile(string inputPath, string outputPath, EncryptionAlgorithm algorithm, SymmetricDecryptOptions options, bool overwrite = false)` -**Returns:** `void` — output file is written to `outputFilePath`. +Reads an encrypted file and writes the plaintext. Throws `InvalidOperationException` if `outputPath` exists and `overwrite` is false. --- -## PGP Decryption - -PGP decrypt methods accept the recipient's private key + passphrase. When `verifySignature: true`, the sender's public key is also required to verify the embedded signature. +## Keyed Hashing -### `byte[] PgpDecryptBytes(...)` +Keyed-hash methods compute an HMAC (or plain hash for non-HMAC algorithms) and return the result as an uppercase hex string. One-way — no inverse operation. -| Overload | Signature | -|----------|-----------| -| String passphrase | `byte[] PgpDecryptBytes(byte[] inputBytes, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false)` | -| SecureString passphrase | `byte[] PgpDecryptBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false)` | +### `string KeyedHashBytes(byte[] input, KeyedHashAlgorithms algorithm, CryptoKey key)` +### `string KeyedHashText(string input, KeyedHashAlgorithms algorithm, CryptoKey key)` +### `string KeyedHashFile(string inputPath, KeyedHashAlgorithms algorithm, CryptoKey key)` -**Returns:** `byte[]` — decrypted plaintext bytes. +**Returns:** `string` — uppercase hex-encoded hash digest. --- -### `string PgpDecryptText(...)` +## PGP Encryption -| Overload | Signature | -|----------|-----------| -| String passphrase | `string PgpDecryptText(string input, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false)` | -| SecureString passphrase | `string PgpDecryptText(string input, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false)` | +If `signer` is supplied, the encrypted payload is also signed with that private key. -**Returns:** `string` — decrypted plaintext. +### `byte[] PgpEncryptBytes(byte[] input, PgpPublicKey recipient, PgpPrivateKey signer = null)` +### `string PgpEncryptText(string input, PgpPublicKey recipient, PgpPrivateKey signer = null)` +### `void PgpEncryptFile(string inputPath, string outputPath, PgpPublicKey recipient, PgpPrivateKey signer = null, bool overwrite = false)` --- -### `void PgpDecryptFile(...)` +## PGP Decryption -| Overload | Signature | -|----------|-----------| -| String passphrase | `void PgpDecryptFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, byte[] publicKey = null, bool verifySignature = false, bool overwrite = false)` | -| SecureString passphrase | `void PgpDecryptFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, byte[] publicKey = null, bool verifySignature = false, bool overwrite = false)` | +If `verifier` is supplied, the embedded signature is verified during decrypt. -**Returns:** `void` — output file is written to `outputFilePath`. +### `byte[] PgpDecryptBytes(byte[] input, PgpPrivateKey recipient, PgpPublicKey verifier = null)` +### `string PgpDecryptText(string input, PgpPrivateKey recipient, PgpPublicKey verifier = null)` +### `void PgpDecryptFile(string inputPath, string outputPath, PgpPrivateKey recipient, PgpPublicKey verifier = null, bool overwrite = false)` --- ## PGP Signing (binary signature) -Produces a detached or embedded binary signature. Verify with `PgpVerify*`. - -### `byte[] PgpSignBytes(...)` +Produces a binary-signed payload. Verify with `PgpVerify*`. -| Overload | Signature | -|----------|-----------| -| String passphrase | `byte[] PgpSignBytes(byte[] inputBytes, byte[] privateKey, string passphrase)` | -| SecureString passphrase | `byte[] PgpSignBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase)` | - -**Returns:** `byte[]` — signed payload. +### `byte[] PgpSignBytes(byte[] input, PgpPrivateKey signer)` +### `string PgpSignText(string input, PgpPrivateKey signer)` +### `void PgpSignFile(string inputPath, string outputPath, PgpPrivateKey signer, bool overwrite = false)` --- -### `string PgpSignText(...)` +## PGP Clear-Signing -| Overload | Signature | -|----------|-----------| -| String passphrase | `string PgpSignText(string input, byte[] privateKey, string passphrase)` | -| SecureString passphrase | `string PgpSignText(string input, byte[] privateKey, SecureString passphrase)` | +Clear-signatures keep the original content human-readable with the signature appended. Verify with `PgpVerifyClearSigned*`. -**Returns:** `string` — ASCII-armored signed payload. - ---- - -### `void PgpSignFile(...)` - -| Overload | Signature | -|----------|-----------| -| String passphrase | `void PgpSignFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, bool overwrite = false)` | -| SecureString passphrase | `void PgpSignFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, bool overwrite = false)` | - -**Returns:** `void` — signed output file is written to `outputFilePath`. - ---- - -## PGP Clearsigning - -Clearsignatures keep the original content human-readable with the signature appended. Verify with `PgpVerifyClear*`. - -### `byte[] PgpClearsignBytes(...)` - -| Overload | Signature | -|----------|-----------| -| String passphrase | `byte[] PgpClearsignBytes(byte[] inputBytes, byte[] privateKey, string passphrase)` | -| SecureString passphrase | `byte[] PgpClearsignBytes(byte[] inputBytes, byte[] privateKey, SecureString passphrase)` | - -**Returns:** `byte[]` — clearsigned payload. - ---- - -### `string PgpClearsignText(...)` - -| Overload | Signature | -|----------|-----------| -| String passphrase | `string PgpClearsignText(string input, byte[] privateKey, string passphrase)` | -| SecureString passphrase | `string PgpClearsignText(string input, byte[] privateKey, SecureString passphrase)` | - -**Returns:** `string` — ASCII-armored clearsigned text. - ---- - -### `void PgpClearsignFile(...)` - -| Overload | Signature | -|----------|-----------| -| String passphrase | `void PgpClearsignFile(string inputFilePath, string outputFilePath, byte[] privateKey, string passphrase, bool overwrite = false)` | -| SecureString passphrase | `void PgpClearsignFile(string inputFilePath, string outputFilePath, byte[] privateKey, SecureString passphrase, bool overwrite = false)` | - -**Returns:** `void` — clearsigned output file is written to `outputFilePath`. +### `byte[] PgpClearSignBytes(byte[] input, PgpPrivateKey signer)` +### `string PgpClearSignText(string input, PgpPrivateKey signer)` +### `void PgpClearSignFile(string inputPath, string outputPath, PgpPrivateKey signer, bool overwrite = false)` --- @@ -347,60 +247,49 @@ Clearsignatures keep the original content human-readable with the signature appe ### Binary signatures -Verify payloads produced by `PgpSign*` (or `PgpEncrypt*` with `sign: true`). +Verify payloads produced by `PgpSign*` (or `PgpEncrypt*` with a signer). | Method | Signature | |--------|-----------| -| Bytes | `bool PgpVerifyBytes(byte[] inputBytes, byte[] publicKey)` | -| Text | `bool PgpVerifyText(string input, byte[] publicKey)` | -| File | `bool PgpVerifyFile(string inputFilePath, byte[] publicKey)` | +| Bytes | `bool PgpVerifyBytes(byte[] input, PgpPublicKey verifier)` | +| Text | `bool PgpVerifyText(string input, PgpPublicKey verifier)` | +| File | `bool PgpVerifyFile(string inputPath, PgpPublicKey verifier)` | **Returns:** `bool` — `true` when the signature is valid; `false` otherwise. ---- - -### Clearsignatures +### Clear-signatures -Verify payloads produced by `PgpClearsign*`. +Verify payloads produced by `PgpClearSign*`. | Method | Signature | |--------|-----------| -| Bytes | `bool PgpVerifyClearBytes(byte[] inputBytes, byte[] publicKey)` | -| Text | `bool PgpVerifyClearText(string input, byte[] publicKey)` | -| File | `bool PgpVerifyClearFile(string inputFilePath, byte[] publicKey)` | - -**Returns:** `bool` — `true` when the clearsignature is valid; `false` otherwise. - ---- +| Bytes | `bool PgpVerifyClearSignedBytes(byte[] input, PgpPublicKey verifier)` | +| Text | `bool PgpVerifyClearSignedText(string input, PgpPublicKey verifier)` | +| File | `bool PgpVerifyClearSignedFile(string inputPath, PgpPublicKey verifier)` | ### Public-key well-formedness -Confirms that the supplied material is a well-formed OpenPGP public key. Mirrors the `PgpVerify` activity's `Mode = PublicKey`. +Confirms that a `PgpPublicKey` instance parses as a well-formed OpenPGP public key. Mirrors the `PgpVerify` activity's `Mode = PublicKey`. -| Method | Signature | -|--------|-----------| -| Bytes | `bool PgpVerifyPublicKeyBytes(byte[] publicKey)` | -| Text | `bool PgpVerifyPublicKeyText(string publicKey)` | -| File | `bool PgpVerifyPublicKeyFile(string publicKeyFilePath)` | +`bool PgpVerifyPublicKey(PgpPublicKey key)` -**Returns:** `bool` — `true` when the input parses as a valid OpenPGP public key. +**Returns:** `bool` — `true` when the key is valid. --- ## PGP Key-Pair Generation -### `void PgpGenerateKeys(string publicKeyPath, string privateKeyPath, string userId, string passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096)` +Generates an OpenPGP RSA key pair **in memory** and returns both halves as a matched `PgpKeyPair`. Persist by calling `Save(path)` on each half. -Generates an OpenPGP RSA key pair and writes both keys to the specified paths. +### `PgpKeyPair PgpGenerateKeys(string userId, string passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096)` +### `PgpKeyPair PgpGenerateKeys(string userId, SecureString passphrase, RsaKeySize keySize = RsaKeySize.Rsa4096)` **Parameters:** -- `publicKeyPath` (`string`) — Path where the ASCII-armored public key is written. -- `privateKeyPath` (`string`) — Path where the ASCII-armored private key is written. - `userId` (`string`) — OpenPGP User ID; conventionally an RFC 2822 mailbox such as `Alice Doe `. -- `passphrase` (`string`) — Passphrase that protects the generated private key. +- `passphrase` — Passphrase that protects the generated private key. Bound to the returned `PgpPrivateKey`. - `keySize` (`RsaKeySize`) — RSA key size. Default `Rsa4096`. `Rsa3072` and `Rsa2048` are accepted for interop with legacy systems. -**Returns:** `void` +**Returns:** `PgpKeyPair` — `pair.PublicKey` and `pair.PrivateKey` (also accessible via deconstruction). --- @@ -421,6 +310,17 @@ Used by `EncryptBytes`/`EncryptText`/`EncryptFile` and `DecryptBytes`/`DecryptTe | `RC2` | RC2 in CBC mode. **`[Obsolete]` — weak; avoid.** | | `PGP` | Reserved. Use the dedicated `PgpEncrypt*`/`PgpDecrypt*` methods instead. | +### `SymmetricWireFormat` + +Used by `SymmetricEncryptOptions.Format` / `SymmetricDecryptOptions.Format`. + +| Value | Notes | +|-------|-------| +| `Classic` | UiPath's byte-stable layout, PBKDF2-HMAC-SHA1 @ 10 000 iter. Default. Frozen for back-compat. | +| `Owasp2026` | Classic layout with OWASP-recommended iter count (1 300 000). Caller can override via `kdfIterations`. | +| `Raw` | `IV ‖ ct [‖ tag]` — caller supplies the literal key (and optionally IV). Third-party interop. | +| `OpenSslEnc` | `Salted__ ‖ salt(8) ‖ ct [‖ tag]`, PBKDF2-HMAC-SHA256 @ 600 000 iter (default). Compatible with `openssl enc -pbkdf2`. | + ### `KeyedHashAlgorithms` Used by `KeyedHashBytes`/`KeyedHashText`/`KeyedHashFile`. @@ -451,54 +351,84 @@ Used by `PgpGenerateKeys`. ## Common Patterns -### Encrypt and decrypt a string with AES-GCM +### Encrypt and decrypt a string with AES-GCM (Classic, the default) ```csharp [Workflow] public void Execute() { - const string key = "MySecretKey123!"; - - var ciphertext = cryptography.EncryptText( - "Sensitive data", - EncryptionAlgorithm.AESGCM, - key, - Encoding.UTF8); + var key = PasswordKey.FromPassword("MySecretKey123!", Encoding.UTF8); + var ciphertext = cryptography.EncryptText("Sensitive data", EncryptionAlgorithm.AESGCM, SymmetricEncryptOptions.Classic(key)); Log($"Encrypted: {ciphertext}"); - var plaintext = cryptography.DecryptText( - ciphertext, - EncryptionAlgorithm.AESGCM, - key, - Encoding.UTF8); - + var plaintext = cryptography.DecryptText(ciphertext, EncryptionAlgorithm.AESGCM, SymmetricDecryptOptions.Classic(key)); Log($"Decrypted: {plaintext}"); } ``` -### Encrypt a file with raw key bytes +### Encrypt with caller-supplied raw key + IV (third-party interop) ```csharp [Workflow] public void Execute() { // 32 bytes → AES-256 - byte[] keyBytes = Convert.FromBase64String("your-base64-encoded-32-byte-key=="); + byte[] rawKeyBytes = Convert.FromBase64String("your-base64-encoded-32-byte-key=="); + byte[] iv = Convert.FromHexString("a3f1b2c4d5e6f70819a0b1c2d3e4f506"); + + var key = RawKey.FromBytes(rawKeyBytes); + + byte[] cipher = cryptography.EncryptBytes( + Encoding.UTF8.GetBytes("payload"), + EncryptionAlgorithm.AESGCM, + SymmetricEncryptOptions.Raw(key, iv)); + + // Decrypt — IV is read from the ciphertext stream prefix; no need to pass it again. + byte[] plain = cryptography.DecryptBytes( + cipher, + EncryptionAlgorithm.AESGCM, + SymmetricDecryptOptions.Raw(key)); +} +``` - cryptography.EncryptFile( - inputFilePath: @"C:\Documents\report.pdf", - outputFilePath: @"C:\Documents\report.pdf.enc", - algorithm: EncryptionAlgorithm.AESGCM, - keyBytes: keyBytes, - overwrite: true); +### Decrypt a file produced by `openssl enc` + +```csharp +[Workflow] +public void Execute() +{ + // openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -md sha256 -salt -k password -in plain.txt -out cipher.bin + var key = PasswordKey.FromPassword("password", Encoding.UTF8); cryptography.DecryptFile( - inputFilePath: @"C:\Documents\report.pdf.enc", - outputFilePath: @"C:\Documents\report_decrypted.pdf", - algorithm: EncryptionAlgorithm.AESGCM, - keyBytes: keyBytes, - overwrite: true); + inputPath: @"C:\Documents\cipher.bin", + outputPath: @"C:\Documents\plain.txt", + algorithm: EncryptionAlgorithm.AES, + options: SymmetricDecryptOptions.OpenSslEnc(key), + overwrite: true); +} +``` + +### Use a stronger KDF iteration count (`Owasp2026`) + +```csharp +[Workflow] +public void Execute() +{ + var key = PasswordKey.FromPassword("MySecretKey", Encoding.UTF8); + + // Owasp2026(key) defaults to kdfIterations = 1_300_000 (the OWASP 2026 recommendation, inlined in the factory signature). + var ciphertext = cryptography.EncryptBytes( + Encoding.UTF8.GetBytes("payload"), + EncryptionAlgorithm.AESGCM, + SymmetricEncryptOptions.Owasp2026(key)); + + // Decrypt must use the same iteration count — Owasp2026 does not store it in the wire format. + byte[] plain = cryptography.DecryptBytes( + ciphertext, + EncryptionAlgorithm.AESGCM, + SymmetricDecryptOptions.Owasp2026(key)); } ``` @@ -509,13 +439,10 @@ public void Execute() public void Execute() { byte[] hmacKey = Convert.FromBase64String("your-base64-hmac-key=="); + var key = RawKey.FromBytes(hmacKey); - var digest = cryptography.KeyedHashText( - "payload to verify", - KeyedHashAlgorithms.HMACSHA256, - hmacKey, - Encoding.UTF8); - + // Keyed-hash methods take a CryptoKey directly — no options object (no wire-format axis). + var digest = cryptography.KeyedHashText("payload to verify", KeyedHashAlgorithms.HMACSHA256, key); Log($"HMAC-SHA256: {digest}"); } ``` @@ -526,42 +453,39 @@ public void Execute() [Workflow] public void Execute() { - byte[] inputBytes = Encoding.UTF8.GetBytes("Confidential message"); - byte[] publicKey = File.ReadAllBytes(@"C:\Keys\recipient_public.asc"); + var recipient = PgpPublicKey.FromFilePath(@"C:\Keys\recipient_public.asc"); + + byte[] encrypted = cryptography.PgpEncryptBytes( + Encoding.UTF8.GetBytes("Confidential message"), + recipient); - byte[] encrypted = cryptography.PgpEncryptBytes(inputBytes, publicKey); File.WriteAllBytes(@"C:\Output\message.pgp", encrypted); } ``` -### PGP encrypt and sign, then decrypt and verify +### PGP encrypt + sign, then decrypt + verify ```csharp [Workflow] public void Execute() { - byte[] inputBytes = Encoding.UTF8.GetBytes("Signed and encrypted message"); - byte[] recipientPublic = File.ReadAllBytes(@"C:\Keys\recipient_public.asc"); - byte[] senderPrivate = File.ReadAllBytes(@"C:\Keys\sender_private.asc"); + var recipientPublic = PgpPublicKey.FromFilePath(@"C:\Keys\recipient_public.asc"); + var senderPrivate = PgpPrivateKey.FromFilePath(@"C:\Keys\sender_private.asc", "senderPassphrase"); - // Encrypt and sign + // Passing a signer to PgpEncrypt* implies sign-and-encrypt. byte[] encrypted = cryptography.PgpEncryptBytes( - inputBytes, - publicKey: recipientPublic, - privateKey: senderPrivate, - passphrase: "senderPassphrase", - sign: true); + Encoding.UTF8.GetBytes("Signed and encrypted message"), + recipientPublic, + signer: senderPrivate); - // Decrypt and verify signature - byte[] recipientPrivate = File.ReadAllBytes(@"C:\Keys\recipient_private.asc"); - byte[] senderPublic = File.ReadAllBytes(@"C:\Keys\sender_public.asc"); + var recipientPrivate = PgpPrivateKey.FromFilePath(@"C:\Keys\recipient_private.asc", "recipientPassphrase"); + var senderPublic = PgpPublicKey.FromFilePath(@"C:\Keys\sender_public.asc"); + // Passing a verifier to PgpDecrypt* implies verify-while-decrypting. byte[] decrypted = cryptography.PgpDecryptBytes( encrypted, - privateKey: recipientPrivate, - passphrase: "recipientPassphrase", - publicKey: senderPublic, - verifySignature: true); + recipientPrivate, + verifier: senderPublic); Log(Encoding.UTF8.GetString(decrypted)); } @@ -573,12 +497,13 @@ public void Execute() [Workflow] public void Execute() { - cryptography.PgpGenerateKeys( - publicKeyPath: @"C:\Keys\my_public.asc", - privateKeyPath: @"C:\Keys\my_private.asc", - userId: "Alice ", - passphrase: "StrongPassphrase!", - keySize: RsaKeySize.Rsa4096); + PgpKeyPair pair = cryptography.PgpGenerateKeys( + userId: "Alice ", + passphrase: "StrongPassphrase!", + keySize: RsaKeySize.Rsa4096); + + pair.PublicKey.Save(@"C:\Keys\my_public.asc"); + pair.PrivateKey.Save(@"C:\Keys\my_private.asc"); Log("Key pair generated."); } @@ -592,12 +517,13 @@ public void Execute() { // armored public key arriving as text from an HTTP response or config string armoredPublicKey = LoadFromInbox(); + var candidate = PgpPublicKey.FromBytes(Encoding.UTF8.GetBytes(armoredPublicKey)); - if (!cryptography.PgpVerifyPublicKeyText(armoredPublicKey)) + if (!cryptography.PgpVerifyPublicKey(candidate)) { throw new InvalidOperationException("Supplied content is not a valid OpenPGP public key."); } - File.WriteAllText(@"C:\Keys\trusted_public.asc", armoredPublicKey); + candidate.Save(@"C:\Keys\trusted_public.asc"); } ``` diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/overview.md b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/overview.md index fa9a06e7e..b196286b4 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/overview.md +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Packaging/docs/overview.md @@ -16,5 +16,5 @@ | [Hash Text](activities/KeyedHashText.md) | Hashes a string with a key using a specified algorithm and returns the hexadecimal string representation of the resulting hash | | [PGP Generate Keys](activities/PgpGenerateKeys.md) | Generates a PGP public/private key pair and saves them to the specified file paths | | [PGP Sign File](activities/PgpSignFile.md) | Creates a PGP binary signature of a file using a private key | -| [PGP Clearsign File](activities/PgpClearsignFile.md) | Creates a PGP clear-text signature of a file using a private key | +| [PGP ClearSign File](activities/PgpClearSignFile.md) | Creates a PGP clear-text signature of a file using a private key | | [PGP Verify](activities/PgpVerify.md) | Verifies a PGP signature, clearsignature, or validates a public key file | diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/CacheMetadataWarningTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/CacheMetadataWarningTests.cs new file mode 100644 index 000000000..efed3a2c1 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/CacheMetadataWarningTests.cs @@ -0,0 +1,127 @@ +using System; +using System.Activities; +using System.Activities.Validation; +using System.Linq; +using Shouldly; +using UiPath.Cryptography.Enums; +using Xunit; + +#pragma warning disable CS0618 // tests intentionally use obsolete algorithms to fire FIPS warnings. + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Coverage for the design-time validation warnings emitted by the + /// EncryptText/DecryptText/EncryptFile/DecryptFile CacheMetadata overrides. The runtime + /// already enforces these constraints; these tests pin the user-facing design-time + /// warning surface so a refactor cannot quietly drop a warning the studio author depends on. + /// + public class CacheMetadataWarningTests + { + // FIPS warning is emitted for algorithms that have no FIPS-compliant implementation. + // Today: RC2, Rijndael, ChaCha20Poly1305, PGP are not FIPS-compliant. Pinning a + // representative non-FIPS algorithm so a future change to the FIPS-compliance map + // doesn't silently drop the warning. + [Theory] + [InlineData(EncryptionAlgorithm.RC2)] + [InlineData(EncryptionAlgorithm.Rijndael)] + public void EncryptText_NonFipsAlgorithm_EmitsFipsWarning(EncryptionAlgorithm algorithm) + { + var activity = new EncryptText { Algorithm = algorithm }; + ValidationError[] warnings = ValidateAndGetWarnings(activity); + + warnings.Any(w => w.Message.Contains("FIPS", StringComparison.Ordinal)).ShouldBeTrue( + $"expected a FIPS warning for {algorithm}, got: {Format(warnings)}"); + } + + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] // FIPS-compliant + [InlineData(EncryptionAlgorithm.AES)] // FIPS-compliant + [InlineData(EncryptionAlgorithm.TripleDES)] // FIPS-compliant + public void EncryptText_FipsAlgorithm_NoFipsWarning(EncryptionAlgorithm algorithm) + { + var activity = new EncryptText { Algorithm = algorithm }; + ValidationError[] warnings = ValidateAndGetWarnings(activity); + + warnings.Any(w => w.Message.Contains("FIPS", StringComparison.Ordinal)).ShouldBeFalse( + $"did not expect FIPS warning for {algorithm}, got: {Format(warnings)}"); + } + + // Iv set on EncryptText (any non-Raw format) → IV nonce-reuse warning. + // The warning text mentions "(Key, IV) pair" so the user can self-serve. + [Fact] + public void EncryptText_WithExplicitIv_EmitsNonceReuseWarning() + { + var activity = new EncryptText + { + Algorithm = EncryptionAlgorithm.AESGCM, + Iv = new InArgument("AABBCCDDEEFF00112233445566778899"), + }; + ValidationError[] warnings = ValidateAndGetWarnings(activity); + + warnings.Any(w => w.Message.Contains("(Key, IV) pair", StringComparison.Ordinal) || w.Message.Contains("explicit IV", StringComparison.Ordinal)).ShouldBeTrue( + $"expected an IV nonce-reuse warning, got: {Format(warnings)}"); + } + + [Fact] + public void EncryptText_WithoutIv_NoNonceReuseWarning() + { + var activity = new EncryptText { Algorithm = EncryptionAlgorithm.AESGCM }; + ValidationError[] warnings = ValidateAndGetWarnings(activity); + + warnings.Any(w => w.Message.Contains("(Key, IV) pair", StringComparison.Ordinal) || w.Message.Contains("nonce", StringComparison.Ordinal)).ShouldBeFalse( + $"did not expect IV nonce-reuse warning, got: {Format(warnings)}"); + } + + // The EncryptFile activity has the same CacheMetadata logic — pin it independently + // since both activities will need to stay aligned. + [Fact] + public void EncryptFile_NonFipsAlgorithm_EmitsFipsWarning() + { + var activity = new EncryptFile { Algorithm = EncryptionAlgorithm.RC2 }; + ValidationError[] warnings = ValidateAndGetWarnings(activity); + + warnings.Any(w => w.Message.Contains("FIPS", StringComparison.Ordinal)).ShouldBeTrue(); + } + + [Fact] + public void EncryptFile_WithExplicitIv_EmitsNonceReuseWarning() + { + var activity = new EncryptFile + { + Algorithm = EncryptionAlgorithm.AES, + Iv = new InArgument("AABBCCDDEEFF00112233445566778899"), + }; + ValidationError[] warnings = ValidateAndGetWarnings(activity); + + warnings.Any(w => w.Message.Contains("(Key, IV) pair", StringComparison.Ordinal) || w.Message.Contains("explicit IV", StringComparison.Ordinal)).ShouldBeTrue(); + } + + // DecryptText/DecryptFile emit FIPS + ChaCha warnings but NOT the IV warning + // (no Iv property on decrypt). Pin that asymmetry — the IV warning is only relevant + // when the user can choose the IV (encryption). + [Fact] + public void DecryptText_NonFipsAlgorithm_EmitsFipsWarning() + { + var activity = new DecryptText { Algorithm = EncryptionAlgorithm.RC2 }; + ValidationError[] warnings = ValidateAndGetWarnings(activity); + + warnings.Any(w => w.Message.Contains("FIPS", StringComparison.Ordinal)).ShouldBeTrue(); + } + + // ──────────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────────── + + // ActivityValidationServices.Validate runs CacheMetadata on the activity tree and + // collects every error/warning into the returned ValidationResults. Then we filter + // to warnings only (isWarning=true entries). + private static ValidationError[] ValidateAndGetWarnings(Activity activity) => + ActivityValidationServices.Validate(activity).Warnings.ToArray(); + + private static string Format(ValidationError[] warnings) => + warnings.Length == 0 ? "(none)" : string.Join("; ", warnings.Select(w => w.Message)); + } +} + +#pragma warning restore CS0618 diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/CryptographyHelperInteropTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/CryptographyHelperInteropTests.cs new file mode 100644 index 000000000..38615a96d --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/CryptographyHelperInteropTests.cs @@ -0,0 +1,214 @@ +using System; +using System.Security.Cryptography; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Enums; +using Xunit; + +#pragma warning disable CS0618 // obsolete algorithms reachable via opt-in formats + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Byte-layout + round-trip coverage of the helper-level wire-format methods on + /// . exercises + /// these through the activity surface; these tests focus on byte-exact assertions + /// the activity-level pass cannot make. + /// + public class CryptographyHelperInteropTests + { + private static readonly byte[] Plain = Encoding.UTF8.GetBytes("helper-interop round-trip 0123456789"); + private static readonly byte[] Password = Encoding.UTF8.GetBytes("helper-interop-pwd"); + + // ──────────────────────────────────────────────────────────────────────── + // Owasp2026 — uses Classic wire layout, caller-supplied PBKDF2-HMAC-SHA1 iter. + // ──────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(EncryptionAlgorithm.AES, 75_000)] + [InlineData(EncryptionAlgorithm.AESGCM, 75_000)] + [InlineData(EncryptionAlgorithm.AES, 1_300_000)] + public void EncryptDataWithIterations_RoundTrips(EncryptionAlgorithm algorithm, int iterations) + { + byte[] cipher = CryptographyHelper.EncryptDataWithIterations(algorithm, Plain, Password, iterations); + byte[] plain = CryptographyHelper.DecryptDataWithIterations(algorithm, cipher, Password, iterations); + plain.ShouldBe(Plain); + } + + // Iteration count is NOT carried in the blob — caller must supply matching values. + // Pin that a mismatched iter on decrypt fails (AEAD via tag check, non-AEAD via padding). + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + public void EncryptDataWithIterations_MismatchedIterationsFails(EncryptionAlgorithm algorithm) + { + byte[] cipher = CryptographyHelper.EncryptDataWithIterations(algorithm, Plain, Password, 50_000); + Should.Throw(() => + CryptographyHelper.DecryptDataWithIterations(algorithm, cipher, Password, 60_000)); + } + + // ──────────────────────────────────────────────────────────────────────── + // OpenSslEnc — Salted__ magic prefix at byte 0; salt randomised per call. + // ──────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(EncryptionAlgorithm.AES)] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.TripleDES)] + public void EncryptDataOpenSslEnc_StartsWithSaltedMagic(EncryptionAlgorithm algorithm) + { + byte[] blob = CryptographyHelper.EncryptDataOpenSslEnc(algorithm, Plain, Password, iterations: 50_000); + Encoding.ASCII.GetString(blob.AsSpan(0, 8).ToArray()).ShouldBe("Salted__"); + } + + [Fact] + public void EncryptDataOpenSslEnc_FreshSaltPerCall() + { + byte[] a = CryptographyHelper.EncryptDataOpenSslEnc(EncryptionAlgorithm.AES, Plain, Password, 50_000); + byte[] b = CryptographyHelper.EncryptDataOpenSslEnc(EncryptionAlgorithm.AES, Plain, Password, 50_000); + + // Same input, same password, same iter → fresh salt yields different salt bytes (and + // therefore different ciphertext). Bytes [8..16] are the salt. + a.AsSpan(8, 8).ToArray().ShouldNotBe(b.AsSpan(8, 8).ToArray()); + a.ShouldNotBe(b); + } + + // Stripping or corrupting the magic prefix makes the blob unreadable. + [Fact] + public void DecryptDataOpenSslEnc_MissingMagic_Throws() + { + byte[] blob = CryptographyHelper.EncryptDataOpenSslEnc(EncryptionAlgorithm.AES, Plain, Password, 50_000); + blob[0] ^= 0x01; // corrupt the first byte of "Salted__" + + Should.Throw(() => + CryptographyHelper.DecryptDataOpenSslEnc(EncryptionAlgorithm.AES, blob, Password, 50_000)); + } + + // ──────────────────────────────────────────────────────────────────────── + // Raw — caller-supplied key + IV. AEAD layout: IV(12) ‖ ct ‖ tag(16); + // non-AEAD: IV(16 for AES) ‖ ct (CBC padded). + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void EncryptDataRaw_AesGcm_LayoutIsIv12PlusCipherPlusTag16() + { + byte[] key = new byte[32]; + byte[] iv = new byte[12]; + RandomNumberGenerator.Fill(key); + RandomNumberGenerator.Fill(iv); + + byte[] blob = CryptographyHelper.EncryptDataRaw(EncryptionAlgorithm.AESGCM, Plain, key, iv); + + // AEAD ciphertext length equals plaintext length (no block padding); IV(12) ‖ ct ‖ tag(16). + blob.Length.ShouldBe(12 + Plain.Length + 16); + blob.AsSpan(0, 12).ToArray().ShouldBe(iv); + } + + [Fact] + public void EncryptDataRaw_AesCbc_LayoutIsIv16PlusPaddedCiphertext() + { + byte[] key = new byte[32]; + byte[] iv = new byte[16]; + RandomNumberGenerator.Fill(key); + RandomNumberGenerator.Fill(iv); + + byte[] blob = CryptographyHelper.EncryptDataRaw(EncryptionAlgorithm.AES, Plain, key, iv); + + // CBC pads plaintext up to a 16-byte block boundary. The padded length is the next + // multiple of 16 strictly greater than Plain.Length (PKCS#7 always adds at least one byte). + int expectedCtLen = ((Plain.Length / 16) + 1) * 16; + blob.Length.ShouldBe(16 + expectedCtLen); + blob.AsSpan(0, 16).ToArray().ShouldBe(iv); + } + + // AEAD IV size must be exactly 12 bytes — pin the validation message so a caller + // who supplies a 16-byte IV (a CBC-style mistake) gets a clear failure. + [Fact] + public void EncryptDataRaw_AeadWith16ByteIv_Throws() + { + byte[] key = new byte[32]; + byte[] iv = new byte[16]; // wrong size for AEAD + Should.Throw(() => + CryptographyHelper.EncryptDataRaw(EncryptionAlgorithm.AESGCM, Plain, key, iv)); + } + + [Fact] + public void EncryptDataRaw_NullIv_GeneratesFresh() + { + byte[] key = new byte[32]; + RandomNumberGenerator.Fill(key); + + byte[] a = CryptographyHelper.EncryptDataRaw(EncryptionAlgorithm.AESGCM, Plain, key, iv: null); + byte[] b = CryptographyHelper.EncryptDataRaw(EncryptionAlgorithm.AESGCM, Plain, key, iv: null); + + // Different IV each call → different ciphertext, and the IV-prefix bytes differ. + a.AsSpan(0, 12).ToArray().ShouldNotBe(b.AsSpan(0, 12).ToArray()); + } + + // ──────────────────────────────────────────────────────────────────────── + // Cross-format failure — using Classic ciphertext with an OpenSslEnc decrypt + // call must fail at the magic-prefix check before any KDF runs. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void DecryptDataOpenSslEnc_ClassicCiphertext_FailsAtMagicCheck() + { + byte[] classicBlob = CryptographyHelper.EncryptData(EncryptionAlgorithm.AES, Plain, Password); + Should.Throw(() => + CryptographyHelper.DecryptDataOpenSslEnc(EncryptionAlgorithm.AES, classicBlob, Password, 600_000)); + } + + // ──────────────────────────────────────────────────────────────────────── + // GetRawKeySizes — AEAD always wants 32 bytes; non-AEAD reports the cipher's + // legal sizes (AES: 16, 24, 32). + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void GetRawKeySizes_Aead_ReturnsOnly32() + { + CryptographyHelper.GetRawKeySizes(EncryptionAlgorithm.AESGCM).ShouldBe(new[] { 32 }); + } + + [Fact] + public void GetRawKeySizes_Aes_ReturnsLegalSizes() + { + CryptographyHelper.GetRawKeySizes(EncryptionAlgorithm.AES).ShouldContain(16); + CryptographyHelper.GetRawKeySizes(EncryptionAlgorithm.AES).ShouldContain(24); + CryptographyHelper.GetRawKeySizes(EncryptionAlgorithm.AES).ShouldContain(32); + } + + // ──────────────────────────────────────────────────────────────────────── + // ParseKeyBytes — hex/base64 routing. Tolerance is covered separately in + // RawKeyTests; here we pin error paths the higher-level model class doesn't reach. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void ParseKeyBytes_Encoded_RequiresEncoding() + { + Should.Throw(() => + CryptographyHelper.ParseKeyBytes("password", keySecureString: null, KeyBytesFormat.Encoded, encoding: null)); + } + + [Fact] + public void ParseKeyBytes_Hex_EmptyInput_Throws() + { + Should.Throw(() => + CryptographyHelper.ParseKeyBytes(string.Empty, keySecureString: null, KeyBytesFormat.Hex, encoding: null)); + } + + [Fact] + public void ParseKeyBytes_Base64_EmptyInput_Throws() + { + Should.Throw(() => + CryptographyHelper.ParseKeyBytes(string.Empty, keySecureString: null, KeyBytesFormat.Base64, encoding: null)); + } + + [Fact] + public void ParseKeyBytes_UnknownFormat_Throws() + { + Should.Throw(() => + CryptographyHelper.ParseKeyBytes("xx", keySecureString: null, (KeyBytesFormat)999, encoding: null)); + } + } +} + +#pragma warning restore CS0618 diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/EncryptTextInteropBranchTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/EncryptTextInteropBranchTests.cs new file mode 100644 index 000000000..5310bac34 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/EncryptTextInteropBranchTests.cs @@ -0,0 +1,321 @@ +using System; +using System.Activities; +using System.Activities.Expressions; +using System.Collections.Generic; +using System.Net; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Enums; +using Xunit; + +#pragma warning disable CS0618 // obsolete algorithms reachable via opt-in formats + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Activity-level branches under the new wire-format arguments that + /// does NOT cover: SecureString key path, + /// non-Unicode key encoding, error-message hints on corrupt input, + /// ContinueOnError behaviour when the symmetric interop path fails. + /// + public class EncryptTextInteropBranchTests + { + private const string Plaintext = "interop-branch-coverage 🎉 ăîșțâ"; + private const string Password = "branch-test-pwd-{$}"; + + // ──────────────────────────────────────────────────────────────────────── + // KeySecureString — the new formats must accept a SecureString key, not just a string. + // ──────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(SymmetricWireFormat.Owasp2026)] + [InlineData(SymmetricWireFormat.OpenSslEnc)] + public void EncryptText_DecryptText_SecureStringKey_RoundTrips(SymmetricWireFormat format) + { + // Encrypt with SecureString, decrypt with SecureString — both ends use the + // KeySecureString path under the new format. Pins that the new dispatch reads + // the secure-string fork, not just the plain-string fork. + SecureString secureKey = ToSecureString(Password); + + string cipher = RunSymmetric( + new EncryptText + { + Algorithm = EncryptionAlgorithm.AESGCM, + Format = format, + KeyFormat = KeyBytesFormat.Encoded, + KeyInputModeSwitch = KeyInputMode.SecureKey, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + }, + input: Plaintext, + secureKey: secureKey); + + string plain = RunSymmetric( + new DecryptText + { + Algorithm = EncryptionAlgorithm.AESGCM, + Format = format, + KeyFormat = KeyBytesFormat.Encoded, + KeyInputModeSwitch = KeyInputMode.SecureKey, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + }, + input: cipher, + secureKey: ToSecureString(Password)); + + plain.ShouldBe(Plaintext); + } + + // Raw with a SecureString-supplied hex key — pins the Hex-from-SecureString path + // (NetworkCredential trick in SymmetricInteropHelper.ParseKeyOrIv). + [Fact] + public void EncryptText_DecryptText_SecureStringHexKey_Raw_RoundTrips() + { + const string hexKey = "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F"; + + string cipher = RunSymmetric( + new EncryptText + { + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.Raw, + KeyFormat = KeyBytesFormat.Hex, + KeyInputModeSwitch = KeyInputMode.SecureKey, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + }, + input: Plaintext, + secureKey: ToSecureString(hexKey)); + + string plain = RunSymmetric( + new DecryptText + { + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.Raw, + KeyFormat = KeyBytesFormat.Hex, + KeyInputModeSwitch = KeyInputMode.SecureKey, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + }, + input: cipher, + secureKey: ToSecureString(hexKey)); + + plain.ShouldBe(Plaintext); + } + + // ──────────────────────────────────────────────────────────────────────── + // Non-Unicode KeyEncoding under Owasp2026 — existing CryptographyTests covers + // Shift-JIS only for the legacy default format. Pin that the new format still + // honours KeyEncodingString. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void EncryptText_DecryptText_Owasp2026_WithShiftJisKey_RoundTrips() + { + const string shiftJisCodePage = "932"; + const string toProcess = "Owasp2026 + Shift-JIS: こんにちは, 1234567890"; + const string key = "shift-jis-key-日本語"; + + var encrypt = new EncryptText + { + Algorithm = EncryptionAlgorithm.AESGCM, + Format = SymmetricWireFormat.Owasp2026, + KeyFormat = KeyBytesFormat.Encoded, + KeyEncodingString = shiftJisCodePage, + }; + string cipher = (string)new WorkflowInvoker(encrypt).Invoke(new Dictionary + { + { nameof(EncryptText.Input), toProcess }, + { nameof(EncryptText.Key), key }, + { nameof(EncryptText.KdfIterations), 50_000 }, + })[nameof(encrypt.Result)]; + + var decrypt = new DecryptText + { + Algorithm = EncryptionAlgorithm.AESGCM, + Format = SymmetricWireFormat.Owasp2026, + KeyFormat = KeyBytesFormat.Encoded, + KeyEncodingString = shiftJisCodePage, + }; + string plain = (string)new WorkflowInvoker(decrypt).Invoke(new Dictionary + { + { nameof(DecryptText.Input), cipher }, + { nameof(DecryptText.Key), key }, + { nameof(DecryptText.KdfIterations), 50_000 }, + })[nameof(decrypt.Result)]; + + plain.ShouldBe(toProcess); + } + + // ──────────────────────────────────────────────────────────────────────── + // Error-message hints on decrypt failures — pin substrings so an actionable + // hint doesn't regress silently into a generic CryptographicException. + // ──────────────────────────────────────────────────────────────────────── + + // Padding failure on a non-AEAD algorithm — message must mention the "different tool" + // hint so the user knows to check their producer. + [Fact] + public void DecryptText_Classic_WrongPassword_NonAead_HintsAtDifferentTool() + { + // Encrypt with one password, decrypt with another. The derived key is wrong, + // so PKCS#7 padding fails on the final block ~255/256 of the time and the + // CryptographicException is wrapped with the "different tool" hint. + string goodCipher = RunSymmetric( + new EncryptText + { + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.Classic, + KeyFormat = KeyBytesFormat.Encoded, + KeyInputModeSwitch = KeyInputMode.Key, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + }, + input: Plaintext, + key: Password); + + InvalidOperationException ex = Should.Throw(() => + RunSymmetric( + new DecryptText + { + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.Classic, + KeyFormat = KeyBytesFormat.Encoded, + KeyInputModeSwitch = KeyInputMode.Key, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + }, + input: goodCipher, + key: "wrong-password-totally-different")); + + ex.InnerException.ShouldBeOfType(); + ex.InnerException.Message.ShouldContain("different tool", Case.Insensitive); + } + + // Input shorter than the wire-format minimum → "UiPath wire format" mention so + // the user can correlate with the docs. + [Fact] + public void DecryptText_Classic_TooShortInput_HintsAtWireFormat() + { + // 4 bytes of base64 — way below the 8+IV minimum. + string tooShort = Convert.ToBase64String(new byte[] { 1, 2, 3, 4 }); + + InvalidOperationException ex = Should.Throw(() => + RunSymmetric( + new DecryptText + { + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.Classic, + KeyFormat = KeyBytesFormat.Encoded, + KeyInputModeSwitch = KeyInputMode.Key, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + }, + input: tooShort, + key: Password)); + + ex.InnerException.ShouldBeOfType(); + ex.InnerException.Message.ShouldContain("UiPath wire format", Case.Insensitive); + } + + // OpenSslEnc input that doesn't start with "Salted__" — the activity should + // surface the missing-magic hint. + [Fact] + public void DecryptText_OpenSslEnc_MissingMagic_SurfacesHint() + { + // 48 bytes of zeros — long enough to clear the length check but bytes 0..7 are not "Salted__". + string notSalted = Convert.ToBase64String(new byte[48]); + + InvalidOperationException ex = Should.Throw(() => + RunSymmetric( + new DecryptText + { + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.OpenSslEnc, + KeyFormat = KeyBytesFormat.Encoded, + KeyInputModeSwitch = KeyInputMode.Key, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + }, + input: notSalted, + key: Password, + iterations: 600_000)); + + ex.InnerException.ShouldBeOfType(); + ex.InnerException.Message.ShouldContain("Salted__"); + } + + // ──────────────────────────────────────────────────────────────────────── + // ContinueOnError — symmetric path should respect the toggle. Today this is + // exercised in EncryptFileTests at the file activity; pin the same behaviour + // through the text activity under the new interop arg surface. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void DecryptText_ContinueOnError_OnBadInteropInput_SwallowsException() + { + string notSalted = Convert.ToBase64String(new byte[48]); + + var decrypt = new DecryptText + { + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.OpenSslEnc, + KeyFormat = KeyBytesFormat.Encoded, + KeyInputModeSwitch = KeyInputMode.Key, + Encoding = MakeEncodingArg(Encoding.UTF8), + KeyEncodingString = null, + ContinueOnError = new InArgument(true), + }; + + Should.NotThrow(() => new WorkflowInvoker(decrypt).Invoke(new Dictionary + { + { nameof(DecryptText.Input), notSalted }, + { nameof(DecryptText.Key), Password }, + { nameof(DecryptText.KdfIterations), 600_000 }, + })); + } + + // ──────────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────────── + + private static InArgument MakeEncodingArg(Encoding e) + { + if (e == Encoding.UTF8) return new InArgument(ExpressionServices.Convert((env) => Encoding.UTF8)); + if (e == Encoding.Unicode || e == null) return new InArgument(ExpressionServices.Convert((env) => Encoding.Unicode)); + throw new ArgumentException($"Test helper only supports UTF-8 and Unicode; got {e.WebName}"); + } + + private static string RunSymmetric(EncryptText activity, string input, string key = null, SecureString secureKey = null, int iterations = 0) + { + var args = new Dictionary + { + [nameof(EncryptText.Input)] = input, + }; + if (key != null) args[nameof(EncryptText.Key)] = key; + if (secureKey != null) args[nameof(EncryptText.KeySecureString)] = secureKey; + if (iterations != 0) args[nameof(EncryptText.KdfIterations)] = iterations; + + try { return (string)new WorkflowInvoker(activity).Invoke(args)[nameof(activity.Result)]; } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) { throw tie.InnerException; } + } + + private static string RunSymmetric(DecryptText activity, string input, string key = null, SecureString secureKey = null, int iterations = 0) + { + var args = new Dictionary + { + [nameof(DecryptText.Input)] = input, + }; + if (key != null) args[nameof(DecryptText.Key)] = key; + if (secureKey != null) args[nameof(DecryptText.KeySecureString)] = secureKey; + if (iterations != 0) args[nameof(DecryptText.KdfIterations)] = iterations; + + try { return (string)new WorkflowInvoker(activity).Invoke(args)[nameof(activity.Result)]; } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) { throw tie.InnerException; } + } + + private static SecureString ToSecureString(string s) => new NetworkCredential(string.Empty, s).SecurePassword; + } +} + +#pragma warning restore CS0618 diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropInProcessTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropInProcessTests.cs new file mode 100644 index 000000000..c23c6f47d --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropInProcessTests.cs @@ -0,0 +1,579 @@ +using System; +using System.Activities; +using System.Activities.Expressions; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using Xunit; + +#pragma warning disable CS0618 // obsolete algorithms reachable via opt-in formats + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Bidirectional external-tool interop tests using BCL-only counterpart implementations + /// of each wire format. Goal: prove that what UiPath produces, an external tool can read, + /// and vice-versa — without depending on an external CLI being present on the CI agent. + /// + /// Each format ships with a Bcl* static helper that emits / parses the same + /// byte layout described in docs/symmetric-wire-format.md, using ONLY + /// , , and . The + /// helpers consult the spec, never the production helper code, so a regression in + /// CryptographyHelper that silently changed the wire format would surface here. + /// + public class ExternalInteropInProcessTests + { + private const string Plaintext = "External-tool interop: round-trip me. 0123456789 ăîșțâ €"; + private const string Password = "interop-test-password-{!@#}"; + + // ──────────────────────────────────────────────────────────────────────── + // OpenSslEnc — AES-256-CBC, bidirectional, AGAINST a BCL counterpart that + // mirrors `openssl enc -aes-256-cbc -pbkdf2 -iter `. This is the headline + // interop format and the whole reason the OpenSslEnc enum exists. + // ──────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(600_000)] + [InlineData(50_000)] + public void OpenSslEnc_AesCbc_ExternalToInternal(int iterations) + { + byte[] plainBytes = Encoding.UTF8.GetBytes(Plaintext); + byte[] externalBlob = BclOpenSslEnc.EncryptAesCbc(plainBytes, Password, iterations); + + string decrypted = RunDecryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, input: Convert.ToBase64String(externalBlob), + inputEncoding: Encoding.UTF8, iterations: iterations); + + Assert.Equal(Plaintext, decrypted); + } + + [Theory] + [InlineData(600_000)] + [InlineData(50_000)] + public void OpenSslEnc_AesCbc_InternalToExternal(int iterations) + { + string encryptedBase64 = RunEncryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, inputEncoding: Encoding.UTF8, iterations: iterations); + + byte[] blob = Convert.FromBase64String(encryptedBase64); + byte[] decrypted = BclOpenSslEnc.DecryptAesCbc(blob, Password, iterations); + + Assert.Equal(Plaintext, Encoding.UTF8.GetString(decrypted)); + } + + // ──────────────────────────────────────────────────────────────────────── + // OpenSslEnc — AES-GCM, bidirectional. This is a UiPath extension of the + // OpenSSL layout (the magic prefix + PBKDF2-SHA256 derivation, but with AEAD + // ciphertext + trailing tag). Both halves must agree because the only + // canonical consumer of this hybrid is "another copy of UiPath". + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void OpenSslEnc_AesGcm_ExternalToInternal() + { + byte[] plainBytes = Encoding.UTF8.GetBytes(Plaintext); + byte[] externalBlob = BclOpenSslEnc.EncryptAesGcm(plainBytes, Password, 600_000); + + string decrypted = RunDecryptText( + EncryptionAlgorithm.AESGCM, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, input: Convert.ToBase64String(externalBlob), + inputEncoding: Encoding.UTF8); + + Assert.Equal(Plaintext, decrypted); + } + + [Fact] + public void OpenSslEnc_AesGcm_InternalToExternal() + { + string encryptedBase64 = RunEncryptText( + EncryptionAlgorithm.AESGCM, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, inputEncoding: Encoding.UTF8); + + byte[] blob = Convert.FromBase64String(encryptedBase64); + byte[] decrypted = BclOpenSslEnc.DecryptAesGcm(blob, Password, 600_000); + + Assert.Equal(Plaintext, Encoding.UTF8.GetString(decrypted)); + } + + // ──────────────────────────────────────────────────────────────────────── + // Classic — UiPath's frozen wire format. The Classic format is NOT a public + // standard so there's no canonical external tool, but the round-trip via an + // independent BCL implementation still proves the layout is stable: a future + // refactor that quietly reorders salt/IV/ciphertext would break the BCL counterpart. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void Classic_AesCbc_ExternalToInternal() + { + byte[] plainBytes = Encoding.UTF8.GetBytes(Plaintext); + byte[] externalBlob = BclClassic.EncryptAesCbc(plainBytes, Password, iterations: 10_000); + + string decrypted = RunDecryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, + password: Password, input: Convert.ToBase64String(externalBlob), + inputEncoding: Encoding.UTF8); + + Assert.Equal(Plaintext, decrypted); + } + + [Fact] + public void Classic_AesCbc_InternalToExternal() + { + string encryptedBase64 = RunEncryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, + password: Password, inputEncoding: Encoding.UTF8); + + byte[] blob = Convert.FromBase64String(encryptedBase64); + byte[] decrypted = BclClassic.DecryptAesCbc(blob, Password, iterations: 10_000); + + Assert.Equal(Plaintext, Encoding.UTF8.GetString(decrypted)); + } + + [Fact] + public void Classic_AesGcm_ExternalToInternal() + { + byte[] plainBytes = Encoding.UTF8.GetBytes(Plaintext); + byte[] externalBlob = BclClassic.EncryptAesGcm(plainBytes, Password, iterations: 10_000); + + string decrypted = RunDecryptText( + EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, + password: Password, input: Convert.ToBase64String(externalBlob), + inputEncoding: Encoding.UTF8); + + Assert.Equal(Plaintext, decrypted); + } + + [Fact] + public void Classic_AesGcm_InternalToExternal() + { + string encryptedBase64 = RunEncryptText( + EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, + password: Password, inputEncoding: Encoding.UTF8); + + byte[] blob = Convert.FromBase64String(encryptedBase64); + byte[] decrypted = BclClassic.DecryptAesGcm(blob, Password, iterations: 10_000); + + Assert.Equal(Plaintext, Encoding.UTF8.GetString(decrypted)); + } + + // ──────────────────────────────────────────────────────────────────────── + // Raw — bidirectional with caller-supplied key and IV. The existing + // SymmetricInteropTests covers one direction for AES and AES-GCM; here we + // add the reverse for AES-GCM (BCL produces, activity consumes) and pin the + // CBC byte layout (IV ‖ ct, no padding tricks). + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void Raw_AesGcm_InternalToExternal() + { + byte[] keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + string hexKey = Convert.ToHexString(keyBytes); + + string encryptedBase64 = RunEncryptText( + EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, + key: hexKey, inputEncoding: Encoding.UTF8); + + byte[] blob = Convert.FromBase64String(encryptedBase64); + byte[] decrypted = BclRaw.DecryptAesGcm(blob, keyBytes); + + Assert.Equal(Plaintext, Encoding.UTF8.GetString(decrypted)); + } + + [Fact] + public void Raw_AesCbc_InternalToExternal_ExplicitIv() + { + byte[] keyBytes = new byte[32]; + byte[] ivBytes = new byte[16]; + RandomNumberGenerator.Fill(keyBytes); + RandomNumberGenerator.Fill(ivBytes); + + string encryptedBase64 = RunEncryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, + key: Convert.ToHexString(keyBytes), + iv: Convert.ToHexString(ivBytes), + inputEncoding: Encoding.UTF8); + + byte[] blob = Convert.FromBase64String(encryptedBase64); + byte[] decrypted = BclRaw.DecryptAesCbc(blob, keyBytes); + + Assert.Equal(Plaintext, Encoding.UTF8.GetString(decrypted)); + } + + [Fact] + public void Raw_AesGcm_ExternalToInternal() + { + byte[] keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + byte[] plainBytes = Encoding.UTF8.GetBytes(Plaintext); + + byte[] externalBlob = BclRaw.EncryptAesGcm(plainBytes, keyBytes); + + string decrypted = RunDecryptText( + EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, + key: Convert.ToHexString(keyBytes), + input: Convert.ToBase64String(externalBlob), + inputEncoding: Encoding.UTF8); + + Assert.Equal(Plaintext, decrypted); + } + + // ──────────────────────────────────────────────────────────────────────── + // BCL counterpart implementations — strictly from the wire-format spec. + // DO NOT call into CryptographyHelper here; the value of these tests is + // that they are an independent implementation. + // ──────────────────────────────────────────────────────────────────────── + + private static class BclOpenSslEnc + { + // "Salted__" — same prefix `openssl enc` writes. + private static readonly byte[] Magic = Encoding.ASCII.GetBytes("Salted__"); + private const int SaltSize = 8; + private const int AesCbcKeySize = 32; + private const int AesCbcIvSize = 16; + private const int AeadKeySize = 32; + private const int AeadIvSize = 12; + private const int AeadTagSize = 16; + + public static byte[] EncryptAesCbc(byte[] plain, string password, int iterations) + { + byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + (byte[] key, byte[] iv) = DeriveKeyAndIv(passwordBytes, salt, iterations, AesCbcKeySize, AesCbcIvSize); + + byte[] cipher; + using (var aes = Aes.Create()) + { + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + using var ms = new MemoryStream(); + using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write)) + cs.Write(plain, 0, plain.Length); + cipher = ms.ToArray(); + } + + return Concat(Magic, salt, cipher); + } + + public static byte[] DecryptAesCbc(byte[] blob, string password, int iterations) + { + AssertMagicPrefix(blob); + byte[] salt = blob.AsSpan(Magic.Length, SaltSize).ToArray(); + byte[] cipher = blob.AsSpan(Magic.Length + SaltSize).ToArray(); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + (byte[] key, byte[] iv) = DeriveKeyAndIv(passwordBytes, salt, iterations, AesCbcKeySize, AesCbcIvSize); + + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var inMs = new MemoryStream(cipher); + using var cs = new CryptoStream(inMs, aes.CreateDecryptor(), CryptoStreamMode.Read); + using var outMs = new MemoryStream(); + cs.CopyTo(outMs); + return outMs.ToArray(); + } + + public static byte[] EncryptAesGcm(byte[] plain, string password, int iterations) + { + byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + (byte[] key, byte[] iv) = DeriveKeyAndIv(passwordBytes, salt, iterations, AeadKeySize, AeadIvSize); + + byte[] cipher = new byte[plain.Length]; + byte[] tag = new byte[AeadTagSize]; + using (var aesGcm = new AesGcm(key)) + aesGcm.Encrypt(iv, plain, cipher, tag); + + return Concat(Magic, salt, cipher, tag); + } + + public static byte[] DecryptAesGcm(byte[] blob, string password, int iterations) + { + AssertMagicPrefix(blob); + byte[] salt = blob.AsSpan(Magic.Length, SaltSize).ToArray(); + int cipherStart = Magic.Length + SaltSize; + int cipherLen = blob.Length - cipherStart - AeadTagSize; + byte[] cipher = blob.AsSpan(cipherStart, cipherLen).ToArray(); + byte[] tag = blob.AsSpan(cipherStart + cipherLen, AeadTagSize).ToArray(); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + (byte[] key, byte[] iv) = DeriveKeyAndIv(passwordBytes, salt, iterations, AeadKeySize, AeadIvSize); + + byte[] plain = new byte[cipherLen]; + using var aesGcm = new AesGcm(key); + aesGcm.Decrypt(iv, cipher, tag, plain); + return plain; + } + + private static (byte[] key, byte[] iv) DeriveKeyAndIv(byte[] password, byte[] salt, int iterations, int keySize, int ivSize) + { + using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256); + byte[] derived = pbkdf2.GetBytes(keySize + ivSize); + byte[] key = derived.AsSpan(0, keySize).ToArray(); + byte[] iv = derived.AsSpan(keySize, ivSize).ToArray(); + return (key, iv); + } + + private static void AssertMagicPrefix(byte[] blob) + { + if (blob.Length < Magic.Length || !blob.AsSpan(0, Magic.Length).SequenceEqual(Magic)) + throw new CryptographicException("Missing 'Salted__' magic prefix."); + } + } + + private static class BclClassic + { + // Classic wire layout: salt(8) ‖ IV ‖ ciphertext [‖ tag(16)]. PBKDF2-HMAC-SHA1. + // The IV is fresh-random per encryption (NOT derived from the password). + private const int SaltSize = 8; + private const int AesCbcKeySize = 32; + private const int AesCbcIvSize = 16; + private const int AeadKeySize = 32; + private const int AeadIvSize = 12; + private const int AeadTagSize = 16; + + public static byte[] EncryptAesCbc(byte[] plain, string password, int iterations) + { + byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + byte[] key = DeriveKey(passwordBytes, salt, iterations, AesCbcKeySize); + + byte[] iv = RandomNumberGenerator.GetBytes(AesCbcIvSize); + byte[] cipher; + using (var aes = Aes.Create()) + { + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + using var ms = new MemoryStream(); + using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write)) + cs.Write(plain, 0, plain.Length); + cipher = ms.ToArray(); + } + + return Concat(salt, iv, cipher); + } + + public static byte[] DecryptAesCbc(byte[] blob, string password, int iterations) + { + byte[] salt = blob.AsSpan(0, SaltSize).ToArray(); + byte[] iv = blob.AsSpan(SaltSize, AesCbcIvSize).ToArray(); + byte[] cipher = blob.AsSpan(SaltSize + AesCbcIvSize).ToArray(); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + byte[] key = DeriveKey(passwordBytes, salt, iterations, AesCbcKeySize); + + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var inMs = new MemoryStream(cipher); + using var cs = new CryptoStream(inMs, aes.CreateDecryptor(), CryptoStreamMode.Read); + using var outMs = new MemoryStream(); + cs.CopyTo(outMs); + return outMs.ToArray(); + } + + public static byte[] EncryptAesGcm(byte[] plain, string password, int iterations) + { + byte[] salt = RandomNumberGenerator.GetBytes(SaltSize); + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + byte[] key = DeriveKey(passwordBytes, salt, iterations, AeadKeySize); + + byte[] iv = RandomNumberGenerator.GetBytes(AeadIvSize); + byte[] cipher = new byte[plain.Length]; + byte[] tag = new byte[AeadTagSize]; + using (var aesGcm = new AesGcm(key)) + aesGcm.Encrypt(iv, plain, cipher, tag); + + return Concat(salt, iv, cipher, tag); + } + + public static byte[] DecryptAesGcm(byte[] blob, string password, int iterations) + { + byte[] salt = blob.AsSpan(0, SaltSize).ToArray(); + byte[] iv = blob.AsSpan(SaltSize, AeadIvSize).ToArray(); + int cipherStart = SaltSize + AeadIvSize; + int cipherLen = blob.Length - cipherStart - AeadTagSize; + byte[] cipher = blob.AsSpan(cipherStart, cipherLen).ToArray(); + byte[] tag = blob.AsSpan(cipherStart + cipherLen, AeadTagSize).ToArray(); + + byte[] passwordBytes = Encoding.UTF8.GetBytes(password); + byte[] key = DeriveKey(passwordBytes, salt, iterations, AeadKeySize); + + byte[] plain = new byte[cipherLen]; + using var aesGcm = new AesGcm(key); + aesGcm.Decrypt(iv, cipher, tag, plain); + return plain; + } + + private static byte[] DeriveKey(byte[] password, byte[] salt, int iterations, int keySize) + { + using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA1); + return pbkdf2.GetBytes(keySize); + } + } + + private static class BclRaw + { + // Raw wire layout: IV ‖ ciphertext [‖ tag(16) for AEAD]. No KDF — caller-supplied raw key. + private const int AesCbcIvSize = 16; + private const int AeadIvSize = 12; + private const int AeadTagSize = 16; + + public static byte[] EncryptAesGcm(byte[] plain, byte[] key) + { + byte[] iv = RandomNumberGenerator.GetBytes(AeadIvSize); + byte[] cipher = new byte[plain.Length]; + byte[] tag = new byte[AeadTagSize]; + using (var aesGcm = new AesGcm(key)) + aesGcm.Encrypt(iv, plain, cipher, tag); + + return Concat(iv, cipher, tag); + } + + public static byte[] DecryptAesGcm(byte[] blob, byte[] key) + { + byte[] iv = blob.AsSpan(0, AeadIvSize).ToArray(); + int cipherLen = blob.Length - AeadIvSize - AeadTagSize; + byte[] cipher = blob.AsSpan(AeadIvSize, cipherLen).ToArray(); + byte[] tag = blob.AsSpan(AeadIvSize + cipherLen, AeadTagSize).ToArray(); + + byte[] plain = new byte[cipherLen]; + using var aesGcm = new AesGcm(key); + aesGcm.Decrypt(iv, cipher, tag, plain); + return plain; + } + + public static byte[] DecryptAesCbc(byte[] blob, byte[] key) + { + byte[] iv = blob.AsSpan(0, AesCbcIvSize).ToArray(); + byte[] cipher = blob.AsSpan(AesCbcIvSize).ToArray(); + + using var aes = Aes.Create(); + aes.Key = key; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + + using var inMs = new MemoryStream(cipher); + using var cs = new CryptoStream(inMs, aes.CreateDecryptor(), CryptoStreamMode.Read); + using var outMs = new MemoryStream(); + cs.CopyTo(outMs); + return outMs.ToArray(); + } + } + + private static byte[] Concat(params byte[][] arrays) + { + int len = 0; + foreach (byte[] a in arrays) len += a.Length; + byte[] result = new byte[len]; + int offset = 0; + foreach (byte[] a in arrays) + { + Buffer.BlockCopy(a, 0, result, offset, a.Length); + offset += a.Length; + } + return result; + } + + // ──────────────────────────────────────────────────────────────────────── + // Activity-surface helpers — match the pattern used in SymmetricInteropTests. + // ──────────────────────────────────────────────────────────────────────── + + private static InArgument MakeEncodingArg(Encoding e) + { + if (e == Encoding.UTF8) return new InArgument(ExpressionServices.Convert((env) => Encoding.UTF8)); + if (e == Encoding.Unicode || e == null) return new InArgument(ExpressionServices.Convert((env) => Encoding.Unicode)); + throw new ArgumentException($"Test helper only supports UTF-8 and Unicode; got {e.WebName}"); + } + + private static string RunEncryptText( + EncryptionAlgorithm algorithm, + SymmetricWireFormat format, + KeyBytesFormat keyFormat, + string password = null, + string key = null, + string iv = null, + int iterations = 0, + Encoding inputEncoding = null) + { + var activity = new EncryptText + { + Algorithm = algorithm, + Format = format, + KeyFormat = keyFormat, + Encoding = MakeEncodingArg(inputEncoding), + KeyEncodingString = null, + }; + + var args = new Dictionary + { + [nameof(EncryptText.Input)] = Plaintext, + [nameof(EncryptText.Key)] = key ?? password, + }; + if (!string.IsNullOrEmpty(iv)) args[nameof(EncryptText.Iv)] = iv; + if (iterations != 0) args[nameof(EncryptText.KdfIterations)] = iterations; + + try + { + var invoker = new WorkflowInvoker(activity); + return (string)invoker.Invoke(args)[nameof(activity.Result)]; + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) + { + throw tie.InnerException; + } + } + + private static string RunDecryptText( + EncryptionAlgorithm algorithm, + SymmetricWireFormat format, + KeyBytesFormat keyFormat, + string input, + string password = null, + string key = null, + int iterations = 0, + Encoding inputEncoding = null) + { + var activity = new DecryptText + { + Algorithm = algorithm, + Format = format, + KeyFormat = keyFormat, + Encoding = MakeEncodingArg(inputEncoding), + KeyEncodingString = null, + }; + + var args = new Dictionary + { + [nameof(DecryptText.Input)] = input, + [nameof(DecryptText.Key)] = key ?? password, + }; + if (iterations != 0) args[nameof(DecryptText.KdfIterations)] = iterations; + + try + { + var invoker = new WorkflowInvoker(activity); + return (string)invoker.Invoke(args)[nameof(activity.Result)]; + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) + { + throw tie.InnerException; + } + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropOpenSslCliTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropOpenSslCliTests.cs new file mode 100644 index 000000000..7470c6f13 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/ExternalInteropOpenSslCliTests.cs @@ -0,0 +1,240 @@ +using System; +using System.Activities; +using System.Activities.Expressions; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Enums; +using Xunit; + +#pragma warning disable CS0618 // obsolete algorithms reachable via opt-in formats + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Live bidirectional interop tests against the OpenSSL CLI. Each test starts by + /// probing for the openssl executable on PATH; if it is missing the + /// test no-ops (passes with no assertion). On CI agents that have OpenSSL these + /// tests prove the spec against the reference implementation, complementing the + /// in-process tests in . + /// + [Trait("Category", "Interop-CLI")] + public class ExternalInteropOpenSslCliTests + { + private const string Plaintext = "OpenSSL CLI interop round-trip. UTF-8 ăîș 0123"; + private const string Password = "openssl-cli-pwd"; + + private static readonly bool OpenSslAvailable = OpenSslCli.Probe(); + + // ──────────────────────────────────────────────────────────────────────── + // openssl enc → UiPath DecryptText + // ──────────────────────────────────────────────────────────────────────── + + // NOTE: UiPath's OpenSslEnc format derives the maximum legal key size for the algorithm + // (AES → 256-bit / 32 bytes), so only aes-256-cbc rounds-trips with the activity surface. + // openssl-side aes-128-cbc would require a key-size knob on the activity that doesn't + // exist today — this is a known limitation, not a test gap. + [Theory] + [InlineData("aes-256-cbc", 600_000)] + [InlineData("aes-256-cbc", 50_000)] + public void OpenSslCli_Encrypts_UiPathDecrypts(string opensslAlgorithm, int iterations) + { + if (!OpenSslAvailable) return; // no-op when openssl is missing + + string plainPath = NewTempPath(); + string cipherPath = NewTempPath(); + try + { + File.WriteAllBytes(plainPath, Encoding.UTF8.GetBytes(Plaintext)); + + // Drive openssl: enc -salt -aes-XXX-cbc -pbkdf2 -iter N -k -in plainPath -out cipherPath + OpenSslCli.Run( + "enc", + $"-{opensslAlgorithm}", + "-pbkdf2", + $"-iter {iterations}", + "-salt", + "-md sha256", + $"-pass pass:{Password}", + $"-in \"{plainPath}\"", + $"-out \"{cipherPath}\""); + + byte[] blob = File.ReadAllBytes(cipherPath); + string base64 = Convert.ToBase64String(blob); + + string decrypted = RunDecryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, input: base64, + inputEncoding: Encoding.UTF8, iterations: iterations); + + decrypted.ShouldBe(Plaintext); + } + finally + { + Cleanup(plainPath, cipherPath); + } + } + + // ──────────────────────────────────────────────────────────────────────── + // UiPath EncryptText → openssl enc -d + // ──────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("aes-256-cbc", 600_000)] + [InlineData("aes-256-cbc", 50_000)] + public void UiPathEncrypts_OpenSslCli_Decrypts(string opensslAlgorithm, int iterations) + { + if (!OpenSslAvailable) return; + + string cipherPath = NewTempPath(); + string outPath = NewTempPath(); + try + { + string encrypted = RunEncryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, + password: Password, iterations: iterations, inputEncoding: Encoding.UTF8); + + File.WriteAllBytes(cipherPath, Convert.FromBase64String(encrypted)); + + OpenSslCli.Run( + "enc", + $"-{opensslAlgorithm}", + "-d", + "-pbkdf2", + $"-iter {iterations}", + "-md sha256", + $"-pass pass:{Password}", + $"-in \"{cipherPath}\"", + $"-out \"{outPath}\""); + + string roundTripped = File.ReadAllText(outPath, Encoding.UTF8); + roundTripped.ShouldBe(Plaintext); + } + finally + { + Cleanup(cipherPath, outPath); + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Test infrastructure + // ──────────────────────────────────────────────────────────────────────── + + private static class OpenSslCli + { +#pragma warning disable CA1031 // Probe is a yes/no detector; any failure (missing exe, IO denied) means "openssl unavailable, skip the CLI tests". + public static bool Probe() + { + try + { + var psi = new ProcessStartInfo("openssl", "version") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var p = Process.Start(psi); + if (p == null) return false; + bool exited = p.WaitForExit(5000); + return exited && p.ExitCode == 0; + } + catch + { + return false; + } + } +#pragma warning restore CA1031 + + public static void Run(params string[] args) + { + var psi = new ProcessStartInfo("openssl", string.Join(" ", args)) + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + using var p = Process.Start(psi); + if (p == null) throw new InvalidOperationException("Could not start openssl"); + if (!p.WaitForExit(10_000)) + { +#pragma warning disable CA1031 // Best-effort kill of a hung child; any failure is swallowed before re-throwing the timeout. + try { p.Kill(); } catch { } +#pragma warning restore CA1031 + throw new TimeoutException("openssl did not exit within 10s"); + } + if (p.ExitCode != 0) + { + string stderr = p.StandardError.ReadToEnd(); + throw new InvalidOperationException($"openssl {string.Join(' ', args)} → exit {p.ExitCode}: {stderr}"); + } + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Activity-surface helpers + // ──────────────────────────────────────────────────────────────────────── + + private static InArgument MakeEncodingArg(Encoding e) + { + if (e == Encoding.UTF8) return new InArgument(ExpressionServices.Convert((env) => Encoding.UTF8)); + if (e == Encoding.Unicode || e == null) return new InArgument(ExpressionServices.Convert((env) => Encoding.Unicode)); + throw new ArgumentException($"Test helper only supports UTF-8 and Unicode; got {e.WebName}"); + } + + private static string RunEncryptText(EncryptionAlgorithm algorithm, SymmetricWireFormat format, KeyBytesFormat keyFormat, + string password = null, int iterations = 0, Encoding inputEncoding = null) + { + var activity = new EncryptText + { + Algorithm = algorithm, + Format = format, + KeyFormat = keyFormat, + Encoding = MakeEncodingArg(inputEncoding), + KeyEncodingString = null, + }; + var args = new Dictionary + { + [nameof(EncryptText.Input)] = Plaintext, + [nameof(EncryptText.Key)] = password, + }; + if (iterations != 0) args[nameof(EncryptText.KdfIterations)] = iterations; + try { return (string)new WorkflowInvoker(activity).Invoke(args)[nameof(activity.Result)]; } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) { throw tie.InnerException; } + } + + private static string RunDecryptText(EncryptionAlgorithm algorithm, SymmetricWireFormat format, KeyBytesFormat keyFormat, + string input, string password = null, int iterations = 0, Encoding inputEncoding = null) + { + var activity = new DecryptText + { + Algorithm = algorithm, + Format = format, + KeyFormat = keyFormat, + Encoding = MakeEncodingArg(inputEncoding), + KeyEncodingString = null, + }; + var args = new Dictionary + { + [nameof(DecryptText.Input)] = input, + [nameof(DecryptText.Key)] = password, + }; + if (iterations != 0) args[nameof(DecryptText.KdfIterations)] = iterations; + try { return (string)new WorkflowInvoker(activity).Invoke(args)[nameof(activity.Result)]; } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) { throw tie.InnerException; } + } + + private static string NewTempPath() => Path.Combine(Path.GetTempPath(), $"openssl_cli_{Guid.NewGuid():N}.bin"); + + private static void Cleanup(params string[] paths) + { + foreach (string p in paths) + if (File.Exists(p)) File.Delete(p); + } + } +} + +#pragma warning restore CS0618 diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/FileInteropTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/FileInteropTests.cs new file mode 100644 index 000000000..6c7ebec4c --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/FileInteropTests.cs @@ -0,0 +1,316 @@ +using System; +using System.Activities; +using System.Activities.Statements; +using System.IO; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Enums; +using Xunit; + +#pragma warning disable CS0618 // tests intentionally use the obsolete KeyInputMode for legacy code paths. + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Round-trip tests for / using + /// the new wire-format arguments (Format, KeyFormat, Iv, + /// KdfIterations). The activity-level wiring exists on the file activities just + /// as it does on the text activities, but until this file the file-form interop path + /// was only covered indirectly through the service-level tests. + /// + public class FileInteropTests + { + private const string Plaintext = "File-form interop round-trip — non-ASCII payload: ăîșțâ €"; + private const string Password = "interop-file-password-{!@#}"; + + [Fact] + public void File_Classic_RoundTrips() + { + RoundTripPasswordBased(EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded); + } + + [Theory] + [InlineData(0)] // service default = 1_300_000 + [InlineData(50_000)] + public void File_Owasp2026_RoundTrips(int iterations) + { + RoundTripPasswordBased(EncryptionAlgorithm.AES, SymmetricWireFormat.Owasp2026, KeyBytesFormat.Encoded, iterations); + } + + [Theory] + [InlineData(0)] // service default = 600_000 + [InlineData(50_000)] + public void File_OpenSslEnc_RoundTrips(int iterations) + { + RoundTripPasswordBased(EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, iterations); + } + + [Theory] + [InlineData(EncryptionAlgorithm.AES, KeyBytesFormat.Hex)] + [InlineData(EncryptionAlgorithm.AESGCM, KeyBytesFormat.Hex)] + [InlineData(EncryptionAlgorithm.AES, KeyBytesFormat.Base64)] + public void File_Raw_RoundTrips_GeneratedIv(EncryptionAlgorithm algorithm, KeyBytesFormat keyFormat) + { + string key = keyFormat == KeyBytesFormat.Hex + ? MakeHexKey(32) + : Convert.ToBase64String(MakeKeyBytes(32)); + + RoundTripRaw(algorithm, keyFormat, key, iv: null); + } + + [Fact] + public void File_Raw_RoundTrips_ExplicitHexIv() + { + // AES-CBC requires 16-byte IV; this pins that the explicit-IV path through + // EncryptFile reaches CryptographyHelper.EncryptDataRaw with the supplied IV. + const string hexKey = "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F"; + const string hexIv = "AABBCCDDEEFF00112233445566778899"; + + string inputPath = MakeTempFile(Plaintext); + string encryptedPath = NewTempPath(); + string decryptedPath = NewTempPath(); + try + { + var encrypt = new EncryptFile + { + InputFilePath = new InArgument(inputPath), + OutputFilePath = new InArgument(encryptedPath), + Key = new InArgument(hexKey), + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.Raw, + KeyFormat = KeyBytesFormat.Hex, + Iv = new InArgument(hexIv), + KeyInputModeSwitch = KeyInputMode.Key, + Overwrite = true, + }; + + var decrypt = new DecryptFile + { + InputFilePath = new InArgument(encryptedPath), + OutputFilePath = new InArgument(decryptedPath), + Key = new InArgument(hexKey), + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.Raw, + KeyFormat = KeyBytesFormat.Hex, + KeyInputModeSwitch = KeyInputMode.Key, + Overwrite = true, + }; + + WorkflowInvoker.Invoke(encrypt); + WorkflowInvoker.Invoke(decrypt); + + File.ReadAllText(decryptedPath, Encoding.UTF8).ShouldBe(Plaintext); + + // The explicit IV must appear as the first 16 bytes of the encrypted blob — + // pins that the Iv argument flows through the activity, not silently regenerated. + byte[] blob = File.ReadAllBytes(encryptedPath); + byte[] ivBytes = HexToBytes(hexIv); + blob.AsSpan(0, 16).ToArray().ShouldBe(ivBytes); + } + finally + { + Cleanup(inputPath, encryptedPath, decryptedPath); + } + } + + // EncryptFile Overwrite=false + existing output must throw across all interop + // formats — the file-existence check sits before the format dispatch. Theory pins + // that no format silently bypasses it. + [Theory] + [InlineData(SymmetricWireFormat.Classic)] + [InlineData(SymmetricWireFormat.Owasp2026)] + [InlineData(SymmetricWireFormat.OpenSslEnc)] + public void File_OverwriteFalse_ExistingOutput_Throws_AcrossFormats(SymmetricWireFormat format) + { + string inputPath = MakeTempFile(Plaintext); + string outputPath = MakeTempFile("existing output"); + try + { + var encrypt = new EncryptFile + { + InputFilePath = new InArgument(inputPath), + OutputFilePath = new InArgument(outputPath), + Key = new InArgument(Password), + Algorithm = EncryptionAlgorithm.AES, + Format = format, + KeyFormat = KeyBytesFormat.Encoded, + KeyInputModeSwitch = KeyInputMode.Key, + Overwrite = false, + }; + + Should.Throw(() => WorkflowInvoker.Invoke(encrypt)); + } + finally + { + Cleanup(inputPath, outputPath); + } + } + + // OutputFileName with no OutputFilePath should still resolve correctly under the + // new interop arguments — pins the FilePathHelpers default-naming behaviour against + // the Format/KeyFormat plumbing. + [Fact] + public void File_OutputFileName_ResolvesInInputDirectory_WithInteropFormat() + { + string inputPath = MakeTempFile(Plaintext); + string inputDir = Path.GetDirectoryName(inputPath); + string outputName = "interop-" + Guid.NewGuid().ToString("N") + ".enc"; + string expectedOutput = Path.Combine(inputDir, outputName); + try + { + var encrypt = new EncryptFile + { + InputFilePath = new InArgument(inputPath), + OutputFileName = new InArgument(outputName), + Key = new InArgument(Password), + Algorithm = EncryptionAlgorithm.AES, + Format = SymmetricWireFormat.Owasp2026, + KeyFormat = KeyBytesFormat.Encoded, + KdfIterations = new InArgument(50_000), + KeyInputModeSwitch = KeyInputMode.Key, + }; + + WorkflowInvoker.Invoke(encrypt); + + File.Exists(expectedOutput).ShouldBeTrue($"expected encrypted output at {expectedOutput}"); + new FileInfo(expectedOutput).Length.ShouldBeGreaterThan(0); + } + finally + { + if (File.Exists(inputPath)) File.Delete(inputPath); + if (File.Exists(expectedOutput)) File.Delete(expectedOutput); + } + } + + // ──────────────────────────────────────────────────────────────────────── + // Helpers + // ──────────────────────────────────────────────────────────────────────── + + private static void RoundTripPasswordBased(EncryptionAlgorithm algorithm, SymmetricWireFormat format, KeyBytesFormat keyFormat, int iterations = 0) + { + string inputPath = MakeTempFile(Plaintext); + string encryptedPath = NewTempPath(); + string decryptedPath = NewTempPath(); + try + { + var encrypt = new EncryptFile + { + InputFilePath = new InArgument(inputPath), + OutputFilePath = new InArgument(encryptedPath), + Key = new InArgument(Password), + Algorithm = algorithm, + Format = format, + KeyFormat = keyFormat, + KeyInputModeSwitch = KeyInputMode.Key, + Overwrite = true, + }; + if (iterations != 0) encrypt.KdfIterations = new InArgument(iterations); + + var decrypt = new DecryptFile + { + InputFilePath = new InArgument(encryptedPath), + OutputFilePath = new InArgument(decryptedPath), + Key = new InArgument(Password), + Algorithm = algorithm, + Format = format, + KeyFormat = keyFormat, + KeyInputModeSwitch = KeyInputMode.Key, + Overwrite = true, + }; + if (iterations != 0) decrypt.KdfIterations = new InArgument(iterations); + + WorkflowInvoker.Invoke(encrypt); + WorkflowInvoker.Invoke(decrypt); + + File.ReadAllText(decryptedPath, Encoding.UTF8).ShouldBe(Plaintext); + } + finally + { + Cleanup(inputPath, encryptedPath, decryptedPath); + } + } + + private static void RoundTripRaw(EncryptionAlgorithm algorithm, KeyBytesFormat keyFormat, string key, string iv) + { + string inputPath = MakeTempFile(Plaintext); + string encryptedPath = NewTempPath(); + string decryptedPath = NewTempPath(); + try + { + var encrypt = new EncryptFile + { + InputFilePath = new InArgument(inputPath), + OutputFilePath = new InArgument(encryptedPath), + Key = new InArgument(key), + Algorithm = algorithm, + Format = SymmetricWireFormat.Raw, + KeyFormat = keyFormat, + KeyInputModeSwitch = KeyInputMode.Key, + Overwrite = true, + }; + if (!string.IsNullOrEmpty(iv)) encrypt.Iv = new InArgument(iv); + + var decrypt = new DecryptFile + { + InputFilePath = new InArgument(encryptedPath), + OutputFilePath = new InArgument(decryptedPath), + Key = new InArgument(key), + Algorithm = algorithm, + Format = SymmetricWireFormat.Raw, + KeyFormat = keyFormat, + KeyInputModeSwitch = KeyInputMode.Key, + Overwrite = true, + }; + + WorkflowInvoker.Invoke(encrypt); + WorkflowInvoker.Invoke(decrypt); + + File.ReadAllText(decryptedPath, Encoding.UTF8).ShouldBe(Plaintext); + } + finally + { + Cleanup(inputPath, encryptedPath, decryptedPath); + } + } + + private static string MakeTempFile(string content) + { + string path = NewTempPath(); + File.WriteAllText(path, content, Encoding.UTF8); + return path; + } + + private static string NewTempPath() => + Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + private static void Cleanup(params string[] paths) + { + foreach (string p in paths) + if (File.Exists(p)) File.Delete(p); + } + + private static string MakeHexKey(int sizeBytes) + { + var sb = new StringBuilder(sizeBytes * 2); + for (int i = 0; i < sizeBytes; i++) sb.Append(((byte)(i + 1)).ToString("X2")); + return sb.ToString(); + } + + private static byte[] MakeKeyBytes(int sizeBytes) + { + byte[] bytes = new byte[sizeBytes]; + for (int i = 0; i < sizeBytes; i++) bytes[i] = (byte)(i + 1); + return bytes; + } + + private static byte[] HexToBytes(string hex) + { + byte[] result = new byte[hex.Length / 2]; + for (int i = 0; i < result.Length; i++) + result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + return result; + } + } +} + +#pragma warning restore CS0618 diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/PgpClearSignFileBranchTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/PgpClearSignFileBranchTests.cs new file mode 100644 index 000000000..ecef2f0d9 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/PgpClearSignFileBranchTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Activities; +using System.IO; +using Shouldly; +using UiPath.Cryptography.Activities; +using UiPath.Cryptography.Enums; +using Xunit; + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Branches of not covered by the round-trip tests + /// in : the class rename, async cancellation, + /// and the IResource property surface. + /// + public class PgpClearSignFileBranchTests : PgpTestBase + { + // The class is exposed under the new PascalCase name. If a future tooling pass + // accidentally reintroduced the old camelCase name (or removed it without an alias), + // this test would break loudly. + [Fact] + public void PgpClearSignFile_TypeName_IsPascalCase() + { + Type t = typeof(PgpClearSignFile); + t.Name.ShouldBe("PgpClearSignFile"); + t.FullName.ShouldBe("UiPath.Cryptography.Activities.PgpClearSignFile"); + + // No stale camelCase type lingering in the assembly. + Type stale = t.Assembly.GetType("UiPath.Cryptography.Activities.PgpClearsignFile", throwOnError: false); + stale.ShouldBeNull("the old PgpClearsignFile (camelCase) name should not exist after the rename"); + } + + // The activity is async — moved to AsyncTaskCodeActivity on this branch. Pin the base type + // so a regression that silently reverts to CodeActivity (and loses the async path) fails here. + [Fact] + public void PgpClearSignFile_DerivesFromAsyncTaskCodeActivity() + { + Type t = typeof(PgpClearSignFile); + Type expectedBase = typeof(UiPath.Shared.Activities.AsyncTaskCodeActivity); + expectedBase.IsAssignableFrom(t).ShouldBeTrue($"expected {t} to derive from {expectedBase}"); + } + + // The activity exposes both string-path and IResource properties — pin both so a + // future rename of one accidentally removes the other. + [Fact] + public void PgpClearSignFile_PropertySurface_HasPairedInputs() + { + Type t = typeof(PgpClearSignFile); + t.GetProperty(nameof(PgpClearSignFile.InputFilePath)).ShouldNotBeNull(); + t.GetProperty(nameof(PgpClearSignFile.InputFile)).ShouldNotBeNull(); + t.GetProperty(nameof(PgpClearSignFile.PrivateKeyFilePath)).ShouldNotBeNull(); + t.GetProperty(nameof(PgpClearSignFile.PrivateKeyFile)).ShouldNotBeNull(); + t.GetProperty(nameof(PgpClearSignFile.Passphrase)).ShouldNotBeNull(); + t.GetProperty(nameof(PgpClearSignFile.PassphraseSecureString)).ShouldNotBeNull(); + t.GetProperty(nameof(PgpClearSignFile.OutputFilePath)).ShouldNotBeNull(); + t.GetProperty(nameof(PgpClearSignFile.ClearSignedFile)).ShouldNotBeNull(); + } + + // Default ContinueOnError (false) — missing input file must surface as a runtime exception + // (mirror of PgpClearSignFile_ContinueOnError_SwallowsResolveFailure with the opposite flag). + [Fact] + public void PgpClearSignFile_DefaultContinueOnError_MissingInput_Throws() + { + string missingInput = Path.Combine(Path.GetTempPath(), $"missing_throw_{Guid.NewGuid():N}.txt"); + string outputPath = Path.Combine(Path.GetTempPath(), $"missing_throw_out_{Guid.NewGuid():N}.asc"); + + var activity = new PgpClearSignFile + { + InputFilePath = new InArgument(missingInput), + PrivateKeyFilePath = new InArgument(_privateKeyPath), + Passphrase = new InArgument(Passphrase), + OutputFilePath = new InArgument(outputPath), + Overwrite = true, + }; + + try + { + Should.Throw(() => WorkflowInvoker.Invoke(activity)); + File.Exists(outputPath).ShouldBeFalse("no output should have been written when input is missing"); + } + finally + { + if (File.Exists(outputPath)) File.Delete(outputPath); + } + } + + // ContinueOnError swallows exceptions during ExecuteAsync — non-existent input file + // exercises the catch block in the async method. + [Fact] + public void PgpClearSignFile_ContinueOnError_SwallowsResolveFailure() + { + string missingInput = Path.Combine(Path.GetTempPath(), $"missing_{Guid.NewGuid():N}.txt"); + string outputPath = Path.Combine(Path.GetTempPath(), $"out_{Guid.NewGuid():N}.asc"); + + var activity = new PgpClearSignFile + { + InputFilePath = new InArgument(missingInput), + PrivateKeyFilePath = new InArgument(_privateKeyPath), + Passphrase = new InArgument(Passphrase), + OutputFilePath = new InArgument(outputPath), + Overwrite = true, + ContinueOnError = new InArgument(true), + }; + + try + { + Should.NotThrow(() => WorkflowInvoker.Invoke(activity)); + File.Exists(outputPath).ShouldBeFalse("no output should have been written when input is missing"); + } + finally + { + if (File.Exists(outputPath)) File.Delete(outputPath); + } + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/PgpStandaloneTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/PgpStandaloneTests.cs index 378b234f4..2b62ba573 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/PgpStandaloneTests.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/PgpStandaloneTests.cs @@ -296,7 +296,7 @@ public void PgpSignFile_Activity_WithStringPaths_Works() } [Fact] - public void PgpClearsignFile_Activity_WithStringPaths_Works() + public void PgpClearSignFile_Activity_WithStringPaths_Works() { var inputPath = Path.Combine(Path.GetTempPath(), $"pgp_clearsign_in_{Guid.NewGuid()}.txt"); var outputPath = Path.Combine(Path.GetTempPath(), $"pgp_clearsign_out_{Guid.NewGuid()}.txt.asc"); @@ -305,7 +305,7 @@ public void PgpClearsignFile_Activity_WithStringPaths_Works() { File.WriteAllText(inputPath, "Hello PGP clearsign"); - var activity = new PgpClearsignFile + var activity = new PgpClearSignFile { InputFilePath = new InArgument(inputPath), PrivateKeyFilePath = new InArgument(_privateKeyPath), @@ -316,7 +316,7 @@ public void PgpClearsignFile_Activity_WithStringPaths_Works() WorkflowInvoker.Invoke(activity); - Assert.True(File.Exists(outputPath), "Clearsigned file not created"); + Assert.True(File.Exists(outputPath), "ClearSigned file not created"); using (var publicKey = File.OpenRead(_publicKeyPath)) { Assert.True(CryptographyHelper.PgpVerifyClear(File.ReadAllBytes(outputPath), publicKey)); @@ -461,7 +461,7 @@ public void PgpSignFile_And_VerifySignature_Activity_RoundTrip() } [Fact] - public void PgpClearsignFile_And_VerifyClearSignature_Activity_RoundTrip() + public void PgpClearSignFile_And_VerifyClearSignature_Activity_RoundTrip() { var inputPath = Path.Combine(Path.GetTempPath(), $"pgp_rtc_in_{Guid.NewGuid()}.txt"); var signedPath = Path.Combine(Path.GetTempPath(), $"pgp_rtc_signed_{Guid.NewGuid()}.asc"); @@ -470,7 +470,7 @@ public void PgpClearsignFile_And_VerifyClearSignature_Activity_RoundTrip() { File.WriteAllText(inputPath, "Round-trip clearsign payload"); - WorkflowInvoker.Invoke(new PgpClearsignFile + WorkflowInvoker.Invoke(new PgpClearSignFile { InputFilePath = new InArgument(inputPath), PrivateKeyFilePath = new InArgument(_privateKeyPath), @@ -530,7 +530,7 @@ public void PgpFullPipeline_Activities_EndToEnd_Works() Overwrite = true, }); - WorkflowInvoker.Invoke(new PgpClearsignFile + WorkflowInvoker.Invoke(new PgpClearSignFile { InputFilePath = new InArgument(signedPath), PrivateKeyFilePath = new InArgument(privPath), @@ -553,7 +553,7 @@ public void PgpFullPipeline_Activities_EndToEnd_Works() InputFilePath = new InArgument(clearSignedPath), PublicKeyFilePath = new InArgument(pubPath), }); - Assert.True((bool)clearResult["Result"], "Clearsignature verification failed"); + Assert.True((bool)clearResult["Result"], "ClearSignature verification failed"); var pubResult = WorkflowInvoker.Invoke(new PgpVerify { @@ -611,9 +611,9 @@ public void PgpSignFile_FilePathMode_EmptyPrivateKeyFilePath_ThrowsAtRuntime() } [Fact] - public void PgpClearsignFile_FilePathMode_EmptyInputFilePath_ThrowsAtRuntime() + public void PgpClearSignFile_FilePathMode_EmptyInputFilePath_ThrowsAtRuntime() { - var activity = new PgpClearsignFile + var activity = new PgpClearSignFile { InputFilePath = new InArgument(""), PrivateKeyFilePath = new InArgument(_privateKeyPath), @@ -621,7 +621,7 @@ public void PgpClearsignFile_FilePathMode_EmptyInputFilePath_ThrowsAtRuntime() OutputFilePath = new InArgument("ignored"), }; var ex = Assert.Throws(() => WorkflowInvoker.Invoke(activity)); - Assert.Equal(nameof(PgpClearsignFile.InputFilePath), ex.ParamName); + Assert.Equal(nameof(PgpClearSignFile.InputFilePath), ex.ParamName); } [Fact] @@ -661,10 +661,10 @@ public void PgpSignFile_Has_IResource_Properties() } [Fact] - public void PgpClearsignFile_Has_IResource_Properties() + public void PgpClearSignFile_Has_IResource_Properties() { - Assert.NotNull(typeof(PgpClearsignFile).GetProperty(nameof(PgpClearsignFile.InputFile))); - Assert.NotNull(typeof(PgpClearsignFile).GetProperty(nameof(PgpClearsignFile.PrivateKeyFile))); + Assert.NotNull(typeof(PgpClearSignFile).GetProperty(nameof(PgpClearSignFile.InputFile))); + Assert.NotNull(typeof(PgpClearSignFile).GetProperty(nameof(PgpClearSignFile.PrivateKeyFile))); } [Fact] diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropHelperTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropHelperTests.cs new file mode 100644 index 000000000..a4394ed84 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropHelperTests.cs @@ -0,0 +1,359 @@ +using System; +using System.Net; +using System.Security; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Enums; +using Xunit; + +#pragma warning disable CS0618 // obsolete algorithms reachable via opt-in formats + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Direct unit coverage for . Today the helper + /// is exercised only indirectly through EncryptText/DecryptText activities; testing + /// it head-on pins every cross-property invariant and routing decision with a clear + /// failure message. + /// + public class SymmetricInteropHelperTests + { + // ──────────────────────────────────────────────────────────────────────── + // ValidateInteropSettings — cross-property invariants + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void Validate_Raw_WithEncodedKeyFormat_Throws() + { + Should.Throw(() => + SymmetricInteropHelper.ValidateInteropSettings( + EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Encoded, + ivString: null, kdfIterations: 0, rawKeyLengthBytes: null)); + } + + [Theory] + [InlineData(SymmetricWireFormat.Classic, KeyBytesFormat.Hex)] + [InlineData(SymmetricWireFormat.Classic, KeyBytesFormat.Base64)] + [InlineData(SymmetricWireFormat.Owasp2026, KeyBytesFormat.Hex)] + [InlineData(SymmetricWireFormat.Owasp2026, KeyBytesFormat.Base64)] + [InlineData(SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Hex)] + [InlineData(SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Base64)] + public void Validate_NonRaw_WithHexOrBase64_Throws(SymmetricWireFormat format, KeyBytesFormat keyFormat) + { + Should.Throw(() => + SymmetricInteropHelper.ValidateInteropSettings( + EncryptionAlgorithm.AES, format, keyFormat, + ivString: null, kdfIterations: 0, rawKeyLengthBytes: null)); + } + + [Theory] + [InlineData(SymmetricWireFormat.Classic)] + [InlineData(SymmetricWireFormat.Owasp2026)] + [InlineData(SymmetricWireFormat.OpenSslEnc)] + public void Validate_NonRaw_WithIv_Throws(SymmetricWireFormat format) + { + Should.Throw(() => + SymmetricInteropHelper.ValidateInteropSettings( + EncryptionAlgorithm.AES, format, KeyBytesFormat.Encoded, + ivString: "AABBCCDDEEFF00112233445566778899", kdfIterations: 0, rawKeyLengthBytes: null)); + } + + [Theory] + [InlineData(SymmetricWireFormat.Classic)] + [InlineData(SymmetricWireFormat.Raw)] + public void Validate_KdfIterations_OnClassicOrRaw_Throws(SymmetricWireFormat format) + { + // Use a key-format that's legal for the chosen wire format so we only trip the iter rule. + KeyBytesFormat kf = format == SymmetricWireFormat.Raw ? KeyBytesFormat.Hex : KeyBytesFormat.Encoded; + Should.Throw(() => + SymmetricInteropHelper.ValidateInteropSettings( + EncryptionAlgorithm.AES, format, kf, + ivString: null, kdfIterations: 100_000, rawKeyLengthBytes: null)); + } + + // Boundary on MinKdfIterations (1000) — 999 throws, 1000 passes, 1001 passes. + // Negative iterations also throw (only 0 means "use the recommended default"; without this, + // a caller passing -1 would silently bypass the floor and run at the default iter count). + [Theory] + [InlineData(999, true)] + [InlineData(1_000, false)] + [InlineData(1_001, false)] + [InlineData(-1, true)] + [InlineData(int.MinValue, true)] + public void Validate_KdfIterations_AtFloor(int iterations, bool shouldThrow) + { + Action act = () => SymmetricInteropHelper.ValidateInteropSettings( + EncryptionAlgorithm.AES, SymmetricWireFormat.Owasp2026, KeyBytesFormat.Encoded, + ivString: null, kdfIterations: iterations, rawKeyLengthBytes: null); + + if (shouldThrow) Should.Throw(act); + else Should.NotThrow(act); + } + + [Fact] + public void Validate_Raw_WithWrongKeyLength_ThrowsWithLegalSizesInMessage() + { + // AES legal raw sizes: 16, 24, 32. Supply 10 (illegal). + var ex = Should.Throw(() => + SymmetricInteropHelper.ValidateInteropSettings( + EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, + ivString: null, kdfIterations: 0, rawKeyLengthBytes: 10)); + + // The user can self-serve if the message names the legal sizes. + ex.Message.ShouldContain("16"); + ex.Message.ShouldContain("24"); + ex.Message.ShouldContain("32"); + } + + // Happy path: a fully-valid setting tuple does not throw. + [Theory] + [InlineData(SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, 0, null)] + [InlineData(SymmetricWireFormat.Owasp2026, KeyBytesFormat.Encoded, 1_300_000, null)] + [InlineData(SymmetricWireFormat.Raw, KeyBytesFormat.Hex, 0, 32)] + [InlineData(SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, 600_000, null)] + public void Validate_ValidSettings_DoNotThrow(SymmetricWireFormat format, KeyBytesFormat keyFormat, int kdfIterations, int? rawKeyLengthBytes) + { + Should.NotThrow(() => + SymmetricInteropHelper.ValidateInteropSettings( + EncryptionAlgorithm.AES, format, keyFormat, + ivString: null, kdfIterations: kdfIterations, rawKeyLengthBytes: rawKeyLengthBytes)); + } + + // ──────────────────────────────────────────────────────────────────────── + // ParseKeyOrIv — value/SecureString/format combinations + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void ParseKeyOrIv_BothEmpty_ReturnsNull() + { + byte[] result = SymmetricInteropHelper.ParseKeyOrIv(value: null, secureValue: null, KeyBytesFormat.Encoded, Encoding.UTF8); + result.ShouldBeNull(); + + byte[] result2 = SymmetricInteropHelper.ParseKeyOrIv(value: string.Empty, secureValue: new SecureString(), KeyBytesFormat.Encoded, Encoding.UTF8); + result2.ShouldBeNull(); + } + + [Fact] + public void ParseKeyOrIv_PlainStringEncoded_ReturnsEncodingBytes() + { + byte[] result = SymmetricInteropHelper.ParseKeyOrIv("ăîș", secureValue: null, KeyBytesFormat.Encoded, Encoding.UTF8); + result.ShouldBe(Encoding.UTF8.GetBytes("ăîș")); + } + + [Fact] + public void ParseKeyOrIv_SecureStringEncoded_ReturnsEncodingBytes() + { + byte[] result = SymmetricInteropHelper.ParseKeyOrIv(value: null, secureValue: ToSecureString("ăîș"), KeyBytesFormat.Encoded, Encoding.UTF8); + result.ShouldBe(Encoding.UTF8.GetBytes("ăîș")); + } + + [Fact] + public void ParseKeyOrIv_PlainStringHex_ProducesBytes() + { + byte[] result = SymmetricInteropHelper.ParseKeyOrIv("0102FF", secureValue: null, KeyBytesFormat.Hex, encoding: null); + result.ShouldBe(new byte[] { 0x01, 0x02, 0xFF }); + } + + // Hex via SecureString routes through the NetworkCredential trick on line 74 of + // SymmetricInteropHelper. Pins that this path produces the same bytes as plain hex. + [Fact] + public void ParseKeyOrIv_SecureStringHex_MatchesPlainHex() + { + byte[] viaPlain = SymmetricInteropHelper.ParseKeyOrIv("DEADBEEF", secureValue: null, KeyBytesFormat.Hex, encoding: null); + byte[] viaSecure = SymmetricInteropHelper.ParseKeyOrIv(value: null, secureValue: ToSecureString("DEADBEEF"), KeyBytesFormat.Hex, encoding: null); + viaSecure.ShouldBe(viaPlain); + } + + [Fact] + public void ParseKeyOrIv_PlainStringBase64_ProducesBytes() + { + byte[] expected = new byte[] { 1, 2, 3, 4, 5 }; + byte[] result = SymmetricInteropHelper.ParseKeyOrIv(Convert.ToBase64String(expected), secureValue: null, KeyBytesFormat.Base64, encoding: null); + result.ShouldBe(expected); + } + + // Precedence: if both plain and secure are populated, plain wins (line 72/74 of helper). + // Pinning this so a future refactor doesn't silently swap the precedence. + [Fact] + public void ParseKeyOrIv_BothSet_PlainStringWins() + { + byte[] result = SymmetricInteropHelper.ParseKeyOrIv("0102", secureValue: ToSecureString("FFFF"), KeyBytesFormat.Hex, encoding: null); + result.ShouldBe(new byte[] { 0x01, 0x02 }); + } + + // ──────────────────────────────────────────────────────────────────────── + // DispatchEncrypt / DispatchDecrypt — each switch arm round-trips, and + // iter=0 picks the recommended default. + // ──────────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(SymmetricWireFormat.Classic, 0)] + [InlineData(SymmetricWireFormat.Owasp2026, 50_000)] + [InlineData(SymmetricWireFormat.OpenSslEnc, 50_000)] + public void Dispatch_PasswordBased_RoundTrips(SymmetricWireFormat format, int iterations) + { + byte[] plain = Encoding.UTF8.GetBytes("dispatch round-trip"); + byte[] password = Encoding.UTF8.GetBytes("dispatch-pwd"); + + byte[] cipher = SymmetricInteropHelper.DispatchEncrypt(EncryptionAlgorithm.AES, format, iterations, password, ivBytes: null, plain); + byte[] decrypted = SymmetricInteropHelper.DispatchDecrypt(EncryptionAlgorithm.AES, format, iterations, password, cipher); + + decrypted.ShouldBe(plain); + } + + [Fact] + public void Dispatch_Raw_RoundTrips() + { + byte[] plain = Encoding.UTF8.GetBytes("dispatch raw round-trip"); + byte[] key = new byte[32]; + for (int i = 0; i < 32; i++) key[i] = (byte)(i + 1); + + byte[] cipher = SymmetricInteropHelper.DispatchEncrypt(EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Raw, kdfIterations: 0, key, ivBytes: null, plain); + byte[] decrypted = SymmetricInteropHelper.DispatchDecrypt(EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Raw, kdfIterations: 0, key, cipher); + + decrypted.ShouldBe(plain); + } + + // Encrypt with Owasp2026 + iter=0 (dispatch picks default 1_300_000) → decrypt + // explicitly with 1_300_000 succeeds. Pins the default-fallback wiring. + [Fact] + public void Dispatch_Owasp2026_IterZero_PicksRecommendedDefault() + { + byte[] plain = Encoding.UTF8.GetBytes("default-iter check"); + byte[] password = Encoding.UTF8.GetBytes("dispatch-pwd"); + + byte[] cipher = SymmetricInteropHelper.DispatchEncrypt(EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Owasp2026, kdfIterations: 0, password, ivBytes: null, plain); + byte[] decrypted = SymmetricInteropHelper.DispatchDecrypt(EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Owasp2026, kdfIterations: 1_300_000, password, cipher); + + decrypted.ShouldBe(plain); + } + + [Fact] + public void Dispatch_OpenSslEnc_IterZero_PicksRecommendedDefault() + { + byte[] plain = Encoding.UTF8.GetBytes("default-iter check"); + byte[] password = Encoding.UTF8.GetBytes("dispatch-pwd"); + + byte[] cipher = SymmetricInteropHelper.DispatchEncrypt(EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, kdfIterations: 0, password, ivBytes: null, plain); + byte[] decrypted = SymmetricInteropHelper.DispatchDecrypt(EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, kdfIterations: 600_000, password, cipher); + + decrypted.ShouldBe(plain); + } + + // ──────────────────────────────────────────────────────────────────────── + // ClearKeyBytes — used by the activity layer to scrub freshly-materialised + // key buffers (from ParseKeyOrIv) after Dispatch returns, so password/raw-key + // material does not linger on the managed heap. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void ClearKeyBytes_ZeroesTheBuffer() + { + byte[] buffer = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; + SymmetricInteropHelper.ClearKeyBytes(buffer); + buffer.ShouldBe(new byte[16]); + } + + [Fact] + public void ClearKeyBytes_NullOrEmpty_NoThrow() + { + Should.NotThrow(() => SymmetricInteropHelper.ClearKeyBytes(null)); + Should.NotThrow(() => SymmetricInteropHelper.ClearKeyBytes(Array.Empty())); + } + + [Fact] + public void Dispatch_UnknownFormat_Throws() + { + // Cast to an out-of-range enum value to force the default branch. + const SymmetricWireFormat invalid = (SymmetricWireFormat)999; + byte[] plain = Encoding.UTF8.GetBytes("x"); + byte[] password = Encoding.UTF8.GetBytes("p"); + + Should.Throw(() => + SymmetricInteropHelper.DispatchEncrypt(EncryptionAlgorithm.AES, invalid, kdfIterations: 0, password, ivBytes: null, plain)); + + Should.Throw(() => + SymmetricInteropHelper.DispatchDecrypt(EncryptionAlgorithm.AES, invalid, kdfIterations: 0, password, new byte[64])); + } + + // ──────────────────────────────────────────────────────────────────────── + // RunSymmetricWithKeyLifecycle — the security-critical invariant the helper exists for + // ──────────────────────────────────────────────────────────────────────── + + // The whole reason the helper owns the key-zeroing finally is so a future per-activity + // fix can't silently drop it for one of the four activities. Capture the parsed key buffer + // from inside the dispatch lambda and assert the bytes are zeroed after the throw escapes — + // exactly what would regress if the finally were ever removed from the helper. + [Fact] + public void RunSymmetricWithKeyLifecycle_DispatchThrows_KeyBytesCleared() + { + byte[] capturedKeyBytes = null; + + Should.Throw(() => + SymmetricInteropHelper.RunSymmetricWithKeyLifecycle( + EncryptionAlgorithm.AES, + SymmetricWireFormat.Classic, + KeyBytesFormat.Encoded, + Encoding.UTF8, + keyString: "key-to-be-cleared", + keySecureString: null, + ivString: null, + kdfIterations: 0, + needsIv: true, + dispatch: (k, _) => + { + capturedKeyBytes = k; + // Sanity: the buffer holds material before we throw. + bool hasContent = false; + for (int i = 0; i < k.Length; i++) if (k[i] != 0) { hasContent = true; break; } + hasContent.ShouldBeTrue("key buffer should contain bytes before the throw"); + throw new InvalidOperationException("forced failure inside dispatch"); + })); + + capturedKeyBytes.ShouldNotBeNull(); + capturedKeyBytes.ShouldBe(new byte[capturedKeyBytes.Length]); + } + + // Happy path: the helper round-trips end-to-end, so call sites can rely on it instead of + // re-implementing validate → parse → dispatch → clear inline. + [Fact] + public void RunSymmetricWithKeyLifecycle_HappyPath_EncryptThenDecrypt_RoundTrip() + { + byte[] plain = Encoding.UTF8.GetBytes("payload"); + string key = "shared-password"; + + byte[] cipher = SymmetricInteropHelper.RunSymmetricWithKeyLifecycle( + EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, Encoding.UTF8, + keyString: key, keySecureString: null, + ivString: null, kdfIterations: 0, needsIv: true, + dispatch: (k, iv) => SymmetricInteropHelper.DispatchEncrypt( + EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, 0, k, iv, plain)); + + byte[] roundTripped = SymmetricInteropHelper.RunSymmetricWithKeyLifecycle( + EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, Encoding.UTF8, + keyString: key, keySecureString: null, + ivString: null, kdfIterations: 0, needsIv: false, + dispatch: (k, _) => SymmetricInteropHelper.DispatchDecrypt( + EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, 0, k, cipher)); + + roundTripped.ShouldBe(plain); + } + + [Fact] + public void RunSymmetricWithKeyLifecycle_NullDispatch_Throws() + { + Should.Throw(() => + SymmetricInteropHelper.RunSymmetricWithKeyLifecycle( + EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, Encoding.UTF8, + keyString: "k", keySecureString: null, + ivString: null, kdfIterations: 0, needsIv: false, + dispatch: null)); + } + + // ──────────────────────────────────────────────────────────────────────── + + private static SecureString ToSecureString(string s) => new NetworkCredential(string.Empty, s).SecurePassword; + } +} + +#pragma warning restore CS0618 diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropTests.cs new file mode 100644 index 000000000..3f80fc33d --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/SymmetricInteropTests.cs @@ -0,0 +1,422 @@ +using System; +using System.Activities; +using System.Activities.Expressions; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Xunit; + +#pragma warning disable CS0618 // obsolete encryption algorithms reachable via opt-in formats + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Tests for the third-party-compatible symmetric formats: Classic, Owasp2026, Raw, OpenSslEnc. + /// Companion to (which exercises the legacy public API). + /// + public class SymmetricInteropTests + { + private const string Plaintext = "Hello, world. The quick brown fox jumps over the lazy dog. 0123456789. ăîșțâ"; + private const string Password = "shared-test-password-{>@#F09"; + + // ------------------------------------------------------------------------------------ + // Classic equivalence: Format = Classic produces output interchangeable with the + // historical default path (no Format property set in XAML). + // ------------------------------------------------------------------------------------ + + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.AES)] + [InlineData(EncryptionAlgorithm.TripleDES)] + public void Classic_RoundTripsViaActivities(EncryptionAlgorithm algorithm) + { + string encrypted = RunEncryptText(algorithm, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, password: Password); + string decrypted = RunDecryptText(algorithm, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, password: Password, input: encrypted); + Assert.Equal(Plaintext, decrypted); + } + + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.AES)] + public void Classic_DecryptedBy_LegacyHelperApi(EncryptionAlgorithm algorithm) + { + // Blob produced by activity (Format = Classic) must decrypt via the legacy + // CryptographyHelper.DecryptData entry point — proves Classic = the existing wire format. + string encryptedBase64 = RunEncryptText(algorithm, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, password: Password); + byte[] decrypted = CryptographyHelper.DecryptData(algorithm, Convert.FromBase64String(encryptedBase64), Encoding.Unicode.GetBytes(Password)); + Assert.Equal(Plaintext, Encoding.Unicode.GetString(decrypted)); + } + + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.AES)] + public void Classic_Decrypts_LegacyHelperOutput(EncryptionAlgorithm algorithm) + { + // Blob produced by legacy CryptographyHelper.EncryptData must decrypt via the new + // activity with Format = Classic — proves byte-stability in the other direction. + byte[] legacyBlob = CryptographyHelper.EncryptData(algorithm, Encoding.Unicode.GetBytes(Plaintext), Encoding.Unicode.GetBytes(Password)); + string activityResult = RunDecryptText(algorithm, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, password: Password, input: Convert.ToBase64String(legacyBlob)); + Assert.Equal(Plaintext, activityResult); + } + + // ------------------------------------------------------------------------------------ + // Owasp2026 round-trip: same wire format as Classic, but iterations is caller-controlled. + // ------------------------------------------------------------------------------------ + + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM, 0)] // default = OWASP 1,300,000 + [InlineData(EncryptionAlgorithm.AESGCM, 50_000)] + [InlineData(EncryptionAlgorithm.AES, 50_000)] + public void Owasp2026_RoundTrips(EncryptionAlgorithm algorithm, int iterations) + { + string encrypted = RunEncryptText(algorithm, SymmetricWireFormat.Owasp2026, KeyBytesFormat.Encoded, password: Password, iterations: iterations); + string decrypted = RunDecryptText(algorithm, SymmetricWireFormat.Owasp2026, KeyBytesFormat.Encoded, password: Password, input: encrypted, iterations: iterations); + Assert.Equal(Plaintext, decrypted); + } + + [Fact] + public void Owasp2026_With10000Iter_DecryptableByClassic() + { + // Owasp2026 is wire-identical to Classic when iterations match — prove it. + string encryptedOwasp2026 = RunEncryptText(EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Owasp2026, KeyBytesFormat.Encoded, password: Password, iterations: 10_000); + string decryptedClassic = RunDecryptText(EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, password: Password, input: encryptedOwasp2026); + Assert.Equal(Plaintext, decryptedClassic); + } + + // ------------------------------------------------------------------------------------ + // Raw round-trip: caller supplies raw key (hex) and optional IV. + // ------------------------------------------------------------------------------------ + + [Theory] + [InlineData(EncryptionAlgorithm.AESGCM)] + [InlineData(EncryptionAlgorithm.AES)] + [InlineData(EncryptionAlgorithm.TripleDES)] + public void Raw_RoundTrips_HexKey_GeneratedIv(EncryptionAlgorithm algorithm) + { + string hexKey = MakeHexKey(CryptographyHelper.GetRawKeySizes(algorithm)[CryptographyHelper.GetRawKeySizes(algorithm).Length - 1]); + string encrypted = RunEncryptText(algorithm, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, key: hexKey); + string decrypted = RunDecryptText(algorithm, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, key: hexKey, input: encrypted); + Assert.Equal(Plaintext, decrypted); + } + + [Fact] + public void Raw_RoundTrips_HexKey_ExplicitIv() + { + const string hexKey = "000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F"; // 32 bytes + const string hexIv = "AABBCCDDEEFF00112233445566778899"; // 16 bytes for AES + string encrypted = RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, key: hexKey, iv: hexIv); + string decrypted = RunDecryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, key: hexKey, input: encrypted); + Assert.Equal(Plaintext, decrypted); + + // The encrypted stream starts with the IV we supplied. + byte[] stream = Convert.FromBase64String(encrypted); + byte[] expectedIv = new byte[16]; + for (int i = 0; i < 16; i++) expectedIv[i] = Convert.ToByte(hexIv.Substring(i * 2, 2), 16); + for (int i = 0; i < 16; i++) Assert.Equal(expectedIv[i], stream[i]); + } + + [Fact] + public void Raw_RoundTrips_Base64Key() + { + string base64Key = Convert.ToBase64String(new byte[32] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32 }); + string encrypted = RunEncryptText(EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Raw, KeyBytesFormat.Base64, key: base64Key); + string decrypted = RunDecryptText(EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Raw, KeyBytesFormat.Base64, key: base64Key, input: encrypted); + Assert.Equal(Plaintext, decrypted); + } + + // ------------------------------------------------------------------------------------ + // OpenSslEnc round-trip: Salted__ + PBKDF2-SHA256. + // ------------------------------------------------------------------------------------ + + [Theory] + [InlineData(EncryptionAlgorithm.AES, 0)] // default 600,000 + [InlineData(EncryptionAlgorithm.AES, 50_000)] + [InlineData(EncryptionAlgorithm.TripleDES, 50_000)] + [InlineData(EncryptionAlgorithm.AESGCM, 50_000)] // UiPath AEAD extension + public void OpenSslEnc_RoundTrips(EncryptionAlgorithm algorithm, int iterations) + { + string encrypted = RunEncryptText(algorithm, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, password: Password, iterations: iterations); + string decrypted = RunDecryptText(algorithm, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, password: Password, input: encrypted, iterations: iterations); + Assert.Equal(Plaintext, decrypted); + } + + [Fact] + public void OpenSslEnc_StreamStartsWithSaltedMagic() + { + string encrypted = RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, password: Password, iterations: 10_000); + byte[] bytes = Convert.FromBase64String(encrypted); + Assert.Equal((byte)'S', bytes[0]); + Assert.Equal((byte)'a', bytes[1]); + Assert.Equal((byte)'l', bytes[2]); + Assert.Equal((byte)'t', bytes[3]); + Assert.Equal((byte)'e', bytes[4]); + Assert.Equal((byte)'d', bytes[5]); + Assert.Equal((byte)'_', bytes[6]); + Assert.Equal((byte)'_', bytes[7]); + } + + [Fact] + public void OpenSslEnc_DecryptingNonSaltedInput_Throws() + { + byte[] junk = Encoding.UTF8.GetBytes("Not a salted__ blob, just text......"); + string input = Convert.ToBase64String(junk); + var ex = Assert.Throws(() => + RunDecryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Encoded, password: Password, input: input, iterations: 10_000)); + Assert.IsType(ex.InnerException); + } + + // ------------------------------------------------------------------------------------ + // Cross-tool interop: produce/consume blobs using .NET's standard primitives. + // No external openssl process required — .NET's Aes/AesGcm produce standard layouts. + // ------------------------------------------------------------------------------------ + + [Fact] + public void Raw_DecryptsBlob_ProducedByDotNetAes() + { + // .NET's Aes.CreateEncryptor outputs the same layout as our Raw format. + byte[] keyBytes = new byte[32]; + byte[] ivBytes = new byte[16]; + RandomNumberGenerator.Fill(keyBytes); + RandomNumberGenerator.Fill(ivBytes); + byte[] plain = Encoding.UTF8.GetBytes(Plaintext); + + byte[] cipher; + using (var aes = Aes.Create()) + { + aes.Key = keyBytes; + aes.IV = ivBytes; + using var ms = new System.IO.MemoryStream(); + using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write)) + cs.Write(plain, 0, plain.Length); + cipher = ms.ToArray(); + } + + byte[] streamBytes = new byte[ivBytes.Length + cipher.Length]; + Buffer.BlockCopy(ivBytes, 0, streamBytes, 0, ivBytes.Length); + Buffer.BlockCopy(cipher, 0, streamBytes, ivBytes.Length, cipher.Length); + + string decrypted = RunDecryptText( + EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, + key: Convert.ToHexString(keyBytes), + input: Convert.ToBase64String(streamBytes), + inputEncoding: Encoding.UTF8); + Assert.Equal(Plaintext, decrypted); + } + + [Fact] + public void Raw_OutputCanBeDecrypted_ByDotNetAesGcm() + { + byte[] keyBytes = new byte[32]; + RandomNumberGenerator.Fill(keyBytes); + + string encryptedBase64 = RunEncryptText( + EncryptionAlgorithm.AESGCM, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, + key: Convert.ToHexString(keyBytes), + inputEncoding: Encoding.UTF8); + + byte[] stream = Convert.FromBase64String(encryptedBase64); + byte[] iv = new byte[12]; + byte[] tag = new byte[16]; + byte[] ct = new byte[stream.Length - iv.Length - tag.Length]; + Buffer.BlockCopy(stream, 0, iv, 0, iv.Length); + Buffer.BlockCopy(stream, iv.Length, ct, 0, ct.Length); + Buffer.BlockCopy(stream, iv.Length + ct.Length, tag, 0, tag.Length); + + byte[] plain = new byte[ct.Length]; + using (var aes = new AesGcm(keyBytes)) + aes.Decrypt(iv, ct, tag, plain); + + Assert.Equal(Plaintext, Encoding.UTF8.GetString(plain)); + } + + // ------------------------------------------------------------------------------------ + // Validation matrix. + // ------------------------------------------------------------------------------------ + + [Fact] + public void Validation_Raw_Encoded_Rejected() + { + Assert.Throws(() => + RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Encoded, password: "anything")); + } + + [Fact] + public void Validation_Classic_Hex_Rejected() + { + Assert.Throws(() => + RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Hex, key: "00112233")); + } + + [Fact] + public void Validation_OpenSslEnc_Base64_Rejected() + { + Assert.Throws(() => + RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.OpenSslEnc, KeyBytesFormat.Base64, key: "AAAA")); + } + + [Fact] + public void Validation_Iv_WithClassic_Rejected() + { + Assert.Throws(() => + RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, password: Password, iv: "deadbeef")); + } + + [Fact] + public void Validation_KdfIterations_OnClassic_Rejected() + { + Assert.Throws(() => + RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Classic, KeyBytesFormat.Encoded, password: Password, iterations: 100_000)); + } + + [Fact] + public void Validation_KdfIterations_OnRaw_Rejected() + { + string hexKey = MakeHexKey(32); + Assert.Throws(() => + RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, key: hexKey, iterations: 100_000)); + } + + [Fact] + public void Validation_KdfIterations_BelowFloor_Rejected() + { + Assert.Throws(() => + RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Owasp2026, KeyBytesFormat.Encoded, password: Password, iterations: 500)); + } + + [Fact] + public void Validation_Raw_WrongKeyLength_Rejected() + { + // AES wants 16/24/32 bytes; supply 10 bytes (20 hex chars). + Assert.Throws(() => + RunEncryptText(EncryptionAlgorithm.AES, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, key: "00112233445566778899")); + } + + [Theory] + [InlineData(EncryptionAlgorithm.TripleDES)] + [InlineData(EncryptionAlgorithm.DES)] + public void Obsolete_Algorithm_AllowedInRaw_BothDirections(EncryptionAlgorithm algorithm) + { + // STUD-64429 — customer interop with legacy systems must be possible even for + // obsolete algorithms. The [Obsolete] compiler warning is the user-facing signal; + // the activity does not block. + int[] sizes = CryptographyHelper.GetRawKeySizes(algorithm); + string hexKey = MakeHexKey(sizes[sizes.Length - 1]); + + string encrypted = RunEncryptText(algorithm, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, key: hexKey); + string decrypted = RunDecryptText(algorithm, SymmetricWireFormat.Raw, KeyBytesFormat.Hex, key: hexKey, input: encrypted); + Assert.Equal(Plaintext, decrypted); + } + + // ------------------------------------------------------------------------------------ + // OWASP defaults sanity check (guards against accidental constant changes). + // ------------------------------------------------------------------------------------ + + [Fact] + public void RecommendedIterations_AreCurrentOwaspValues() + { + Assert.Equal(1_300_000, CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.Owasp2026)); + Assert.Equal(600_000, CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.OpenSslEnc)); + } + + [Fact] + public void RecommendedIterations_ForClassicOrRaw_Throws() + { + Assert.Throws(() => CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.Classic)); + Assert.Throws(() => CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.Raw)); + } + + // ------------------------------------------------------------------------------------ + // Test harness helpers. + // ------------------------------------------------------------------------------------ + + // WF Literal rejects closure-capturing lambdas, so encoding selection uses static lambdas. + private static InArgument MakeEncodingArg(Encoding e) + { + if (e == Encoding.Unicode || e == null) return new InArgument(ExpressionServices.Convert((env) => Encoding.Unicode)); + if (e == Encoding.UTF8) return new InArgument(ExpressionServices.Convert((env) => Encoding.UTF8)); + throw new ArgumentException($"Test helper only supports Unicode/UTF8; got {e.WebName}"); + } + + private static string RunEncryptText( + EncryptionAlgorithm algorithm, + SymmetricWireFormat format, + KeyBytesFormat keyFormat, + string password = null, + string key = null, + string iv = null, + int iterations = 0, + Encoding inputEncoding = null) + { + var activity = new EncryptText + { + Algorithm = algorithm, + Format = format, + KeyFormat = keyFormat, + Encoding = MakeEncodingArg(inputEncoding), + KeyEncodingString = null, + }; + + var args = new Dictionary + { + [nameof(EncryptText.Input)] = Plaintext, + [nameof(EncryptText.Key)] = key ?? password, + }; + if (!string.IsNullOrEmpty(iv)) args[nameof(EncryptText.Iv)] = iv; + if (iterations != 0) args[nameof(EncryptText.KdfIterations)] = iterations; + + try + { + var invoker = new WorkflowInvoker(activity); + return (string)invoker.Invoke(args)[nameof(activity.Result)]; + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) + { + throw tie.InnerException; + } + } + + private static string RunDecryptText( + EncryptionAlgorithm algorithm, + SymmetricWireFormat format, + KeyBytesFormat keyFormat, + string input, + string password = null, + string key = null, + int iterations = 0, + Encoding inputEncoding = null) + { + var activity = new DecryptText + { + Algorithm = algorithm, + Format = format, + KeyFormat = keyFormat, + Encoding = MakeEncodingArg(inputEncoding), + KeyEncodingString = null, + }; + + var args = new Dictionary + { + [nameof(DecryptText.Input)] = input, + [nameof(DecryptText.Key)] = key ?? password, + }; + if (iterations != 0) args[nameof(DecryptText.KdfIterations)] = iterations; + + try + { + var invoker = new WorkflowInvoker(activity); + return (string)invoker.Invoke(args)[nameof(activity.Result)]; + } + catch (System.Reflection.TargetInvocationException tie) when (tie.InnerException != null) + { + throw tie.InnerException; + } + } + + private static string MakeHexKey(int sizeBytes) + { + var sb = new StringBuilder(sizeBytes * 2); + for (int i = 0; i < sizeBytes; i++) sb.Append(((byte)(i + 1)).ToString("X2")); + return sb.ToString(); + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/WireFormatStabilityTests.cs b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/WireFormatStabilityTests.cs new file mode 100644 index 000000000..decd8dae4 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography.Activities.Tests/WireFormatStabilityTests.cs @@ -0,0 +1,165 @@ +using System; +using System.Text; +using Shouldly; +using UiPath.Cryptography.Enums; +using Xunit; + +#pragma warning disable CS0618 // obsolete algorithms reachable via opt-in formats + +namespace UiPath.Cryptography.Activities.Tests +{ + /// + /// Pin the wire-format byte layouts so a future refactor cannot silently break + /// historical ciphertext. Each blob below was produced by an earlier run of + /// CryptographyHelper.* and is intentionally hardcoded — if any of these tests + /// starts failing, the wire layout has moved and existing customer ciphertext + /// in the wild will no longer decrypt. + /// + /// The marker plaintext / password / key are fixed; only the salt and IV inside + /// each blob were randomised at capture time. Future encryption will produce + /// different bytes (salt/IV are still random), but DECRYPTING these captured + /// blobs must keep working forever. + /// + public class WireFormatStabilityTests + { + private const string Plaintext = "wire-format-stability-marker"; + private const string Password = "fixture-pass-stable-{!}"; + + // Classic (Frozen): salt(8) ‖ IV(16) ‖ ciphertext. PBKDF2-HMAC-SHA1 @ 10,000 iter. + private const string ClassicAesCbcBlob = "12D787E3818ABE0CA99AD3015FA71B0E1A5D737971CB25DCB5629C6556D0CE7BE9E03CF9DED3E7BE7CA7A36E83C764348C4569CD39BAD234"; + + // Classic (AEAD): salt(8) ‖ IV(12) ‖ ciphertext ‖ tag(16). PBKDF2-HMAC-SHA1 @ 10,000 iter. + private const string ClassicAesGcmBlob = "CB5CC5EDE5DB0961118FF88E03DB60D22B997C40012E6D073C38A5931214734F3548590391700A89F6619F2A54CEE9CC2D0E5E0CC595551416076C7E9715B803"; + + // Owasp2026 (AEAD): same layout as Classic, PBKDF2-HMAC-SHA1 @ 1,300,000 iter. + private const string Owasp2026AesGcmBlob_1300000Iter = "55B8B76EC51F317C05DB0227D549265AD7D595F072555A9E741700ABF6D41C74B0EE142238CC2739E12CF561B39CD05668CD050890EB0ED1DFF1BD2169F9A910"; + + // OpenSslEnc CBC: "Salted__"(8) ‖ salt(8) ‖ ciphertext. PBKDF2-HMAC-SHA256 @ 600,000 iter. + private const string OpenSslEncAesCbcBlob_600000Iter = "53616C7465645F5FD9652D18E7589DCF66B5643EF4F9FDD1612AEFD72D7BA232725936111004B08A29786D5B9E876CD0"; + + // OpenSslEnc AEAD (UiPath extension): "Salted__"(8) ‖ salt(8) ‖ ciphertext ‖ tag(16). PBKDF2-HMAC-SHA256 @ 600,000 iter. + private const string OpenSslEncAesGcmBlob_600000Iter = "53616C7465645F5FB6F179A6913F02F7CB6328DA9D8AE3109DD98963E45E457530DEAD3DE5ABE3E7518392A112F016E2FBB77AAF86913DBC2AE75DCC"; + + // Raw AEAD: IV(12) ‖ ciphertext ‖ tag(16). No KDF. + private const string RawAesGcmBlob_FixedKeyIv = "808182838485868788898A8B9BBF2498CD86A4C24E2688170FB04D8D20E4147741BB950821885C6BEF69959B17C57B58223D3C531F837D7E"; + + // ──────────────────────────────────────────────────────────────────────── + // Classic — frozen byte-stable layout. Any change to PBKDF2 iterations, + // salt length, KDF hash algorithm, or byte ordering will fail these. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void Classic_AesCbc_FixedBlob_Decrypts() + { + byte[] decrypted = CryptographyHelper.DecryptData( + EncryptionAlgorithm.AES, + Convert.FromHexString(ClassicAesCbcBlob), + Encoding.UTF8.GetBytes(Password)); + Encoding.UTF8.GetString(decrypted).ShouldBe(Plaintext); + } + + [Fact] + public void Classic_AesGcm_FixedBlob_Decrypts() + { + byte[] decrypted = CryptographyHelper.DecryptData( + EncryptionAlgorithm.AESGCM, + Convert.FromHexString(ClassicAesGcmBlob), + Encoding.UTF8.GetBytes(Password)); + Encoding.UTF8.GetString(decrypted).ShouldBe(Plaintext); + } + + // ──────────────────────────────────────────────────────────────────────── + // Owasp2026 — same layout as Classic, but caller-supplied iter count. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void Owasp2026_AesGcm_FixedBlob_AtRecommendedIterations_Decrypts() + { + byte[] decrypted = CryptographyHelper.DecryptDataWithIterations( + EncryptionAlgorithm.AESGCM, + Convert.FromHexString(Owasp2026AesGcmBlob_1300000Iter), + Encoding.UTF8.GetBytes(Password), + iterations: 1_300_000); + Encoding.UTF8.GetString(decrypted).ShouldBe(Plaintext); + } + + // ──────────────────────────────────────────────────────────────────────── + // OpenSslEnc — third-party interop layout. If THIS test fails, the cross-tool + // interop guarantee that motivated the OpenSslEnc feature is broken. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void OpenSslEnc_AesCbc_FixedBlob_Decrypts() + { + byte[] decrypted = CryptographyHelper.DecryptDataOpenSslEnc( + EncryptionAlgorithm.AES, + Convert.FromHexString(OpenSslEncAesCbcBlob_600000Iter), + Encoding.UTF8.GetBytes(Password), + iterations: 600_000); + Encoding.UTF8.GetString(decrypted).ShouldBe(Plaintext); + } + + [Fact] + public void OpenSslEnc_AesGcm_FixedBlob_Decrypts() + { + byte[] decrypted = CryptographyHelper.DecryptDataOpenSslEnc( + EncryptionAlgorithm.AESGCM, + Convert.FromHexString(OpenSslEncAesGcmBlob_600000Iter), + Encoding.UTF8.GetBytes(Password), + iterations: 600_000); + Encoding.UTF8.GetString(decrypted).ShouldBe(Plaintext); + } + + // OpenSslEnc CBC blob must start with the canonical "Salted__" prefix — + // a test that catches any silent change to the magic constant or its position. + [Fact] + public void OpenSslEnc_AesCbc_FixedBlob_BeginsWithSaltedMagic() + { + byte[] blob = Convert.FromHexString(OpenSslEncAesCbcBlob_600000Iter); + Encoding.ASCII.GetString(blob.AsSpan(0, 8).ToArray()).ShouldBe("Salted__"); + } + + // ──────────────────────────────────────────────────────────────────────── + // Raw — caller-supplied key + IV, no KDF. The fixed IV at the start of the + // blob is the literal IV the captured encryption used; this also pins the + // AEAD tag layout (tag goes at the END of the blob, not the start). + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void Raw_AesGcm_FixedBlob_Decrypts() + { + byte[] rawKey = new byte[32]; + for (int i = 0; i < 32; i++) rawKey[i] = (byte)(i + 1); + + byte[] decrypted = CryptographyHelper.DecryptDataRaw( + EncryptionAlgorithm.AESGCM, + Convert.FromHexString(RawAesGcmBlob_FixedKeyIv), + rawKey); + Encoding.UTF8.GetString(decrypted).ShouldBe(Plaintext); + } + + [Fact] + public void Raw_AesGcm_FixedBlob_BeginsWithFixedIv() + { + // Pin that the IV the encryptor was told to use appears as the stream prefix. + byte[] blob = Convert.FromHexString(RawAesGcmBlob_FixedKeyIv); + byte[] expectedIv = new byte[12]; + for (int i = 0; i < 12; i++) expectedIv[i] = (byte)(0x80 + i); + blob.AsSpan(0, 12).ToArray().ShouldBe(expectedIv); + } + + // ──────────────────────────────────────────────────────────────────────── + // OWASP iteration constants — pinned values so an accidental edit to the + // private constants in CryptographyHelper will fail loudly. + // ──────────────────────────────────────────────────────────────────────── + + [Fact] + public void RecommendedIterations_PinValues() + { + CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.Owasp2026).ShouldBe(1_300_000); + CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.OpenSslEnc).ShouldBe(600_000); + } + } +} + +#pragma warning restore CS0618 diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptFile.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptFile.cs index e3309dfaf..99c22748c 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptFile.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptFile.cs @@ -67,6 +67,24 @@ public class DecryptFile : CodeActivity [Browsable(false)] public InArgument KeyEncodingString { get; set; } + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_DecryptFile_Property_Format_Name))] + [LocalizedDescription(nameof(Resources.Activity_DecryptFile_Property_Format_Description))] + [DefaultValue(SymmetricWireFormat.Classic)] + public SymmetricWireFormat Format { get; set; } + + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_DecryptFile_Property_KeyFormat_Name))] + [LocalizedDescription(nameof(Resources.Activity_DecryptFile_Property_KeyFormat_Description))] + [DefaultValue(KeyBytesFormat.Encoded)] + public KeyBytesFormat KeyFormat { get; set; } + + [DefaultValue(null)] + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_DecryptFile_Property_KdfIterations_Name))] + [LocalizedDescription(nameof(Resources.Activity_DecryptFile_Property_KdfIterations_Description))] + public InArgument KdfIterations { get; set; } + [Browsable(false)] [Obsolete("Legacy property kept for XAML back-compat with workflows that persisted the active file input mode. The activity now infers the mode from which side is bound.")] public FileInputMode FileInputModeSwitch { get; set; } @@ -185,7 +203,7 @@ protected override void Execute(CodeActivityContext context) var decrypted = Algorithm == EncryptionAlgorithm.PGP ? ExecutePgpDecrypt(context, encrypted) - : ExecuteSymmetricDecrypt(encrypted, keyEncoding, key, keySecureString); + : ExecuteSymmetricDecrypt(context, encrypted, keyEncoding, key, keySecureString); WriteDecryptedOutput(context, outputFilePath, decrypted, result); #if ENABLE_DEFAULT_TELEMETRY @@ -259,16 +277,25 @@ private byte[] ExecutePgpDecrypt(CodeActivityContext context, byte[] encrypted) CryptographyHelper.PgpDecrypt(encrypted, privStream, pass, pubStream, VerifySignature)); } - private byte[] ExecuteSymmetricDecrypt(byte[] encrypted, Encoding keyEncoding, string key, SecureString keySecureString) + private byte[] ExecuteSymmetricDecrypt(CodeActivityContext context, byte[] encrypted, Encoding keyEncoding, string key, SecureString keySecureString) { - try - { - return CryptographyHelper.DecryptData(Algorithm, encrypted, CryptographyHelper.KeyEncoding(keyEncoding, key, keySecureString)); - } - catch (CryptographicException ex) - { - throw new InvalidOperationException(Resources.GenericCryptographicException, ex); - } + var iterations = KdfIterations?.Get(context) ?? 0; + + return SymmetricInteropHelper.RunSymmetricWithKeyLifecycle( + Algorithm, Format, KeyFormat, keyEncoding, + keyString: key, keySecureString: keySecureString, + ivString: null, kdfIterations: iterations, needsIv: false, + dispatch: (k, _) => + { + try + { + return SymmetricInteropHelper.DispatchDecrypt(Algorithm, Format, iterations, k, encrypted); + } + catch (CryptographicException ex) + { + throw new InvalidOperationException(Resources.GenericCryptographicException, ex); + } + }); } 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 a63d8823c..0218ed06d 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptText.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/DecryptText.cs @@ -58,6 +58,24 @@ public partial class DecryptText : CodeActivity [Browsable(false)] public InArgument KeyEncodingString { get; set; } + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_DecryptText_Property_Format_Name))] + [LocalizedDescription(nameof(Resources.Activity_DecryptText_Property_Format_Description))] + [DefaultValue(SymmetricWireFormat.Classic)] + public SymmetricWireFormat Format { get; set; } + + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_DecryptText_Property_KeyFormat_Name))] + [LocalizedDescription(nameof(Resources.Activity_DecryptText_Property_KeyFormat_Description))] + [DefaultValue(KeyBytesFormat.Encoded)] + public KeyBytesFormat KeyFormat { get; set; } + + [DefaultValue(null)] + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_DecryptText_Property_KdfIterations_Name))] + [LocalizedDescription(nameof(Resources.Activity_DecryptText_Property_KdfIterations_Description))] + public InArgument KdfIterations { get; set; } + [LocalizedCategory(nameof(Resources.Output))] [LocalizedDisplayName(nameof(Resources.Activity_DecryptText_Property_Result_Name))] [LocalizedDescription(nameof(Resources.Activity_DecryptText_Property_Result_Description))] @@ -198,27 +216,35 @@ private string ExecuteSymmetricDecrypt(CodeActivityContext context, string input var keySecureString = KeySecureString.Get(context); var keyEncoding = Encoding.Get(context); var keyEncodingString = KeyEncodingString.Get(context); + var iterations = KdfIterations?.Get(context) ?? 0; if (string.IsNullOrWhiteSpace(key)) { if (keySecureString == null || keySecureString.Length == 0) throw new ArgumentNullException(nameof(Key), Resources.Activity_DecryptText_Property_Key_Name); - key = null; // ensure helper falls back to SecureString + key = null; } if (keyEncoding == null && string.IsNullOrEmpty(keyEncodingString)) throw new ArgumentNullException(Resources.Encoding); keyEncoding = EncodingHelpers.KeyEncodingOrString(keyEncoding, keyEncodingString); - byte[] decrypted; - try - { - decrypted = CryptographyHelper.DecryptData(Algorithm, Convert.FromBase64String(input), CryptographyHelper.KeyEncoding(keyEncoding, key, keySecureString)); - } - catch (CryptographicException ex) - { - throw new InvalidOperationException(Resources.GenericCryptographicException, ex); - } + byte[] decrypted = SymmetricInteropHelper.RunSymmetricWithKeyLifecycle( + Algorithm, Format, KeyFormat, keyEncoding, + keyString: key, keySecureString: keySecureString, + ivString: null, kdfIterations: iterations, needsIv: false, + dispatch: (k, _) => + { + try + { + return SymmetricInteropHelper.DispatchDecrypt( + Algorithm, Format, iterations, k, Convert.FromBase64String(input)); + } + catch (CryptographicException ex) + { + throw new InvalidOperationException(Resources.GenericCryptographicException, ex); + } + }); return keyEncoding.GetString(decrypted); } diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/EncryptFile.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/EncryptFile.cs index 5ef309b83..867b66e24 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/EncryptFile.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/EncryptFile.cs @@ -76,6 +76,30 @@ public EncryptFile() [Browsable(false)] public InArgument KeyEncodingString { get; set; } + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_EncryptFile_Property_Format_Name))] + [LocalizedDescription(nameof(Resources.Activity_EncryptFile_Property_Format_Description))] + [DefaultValue(SymmetricWireFormat.Classic)] + public SymmetricWireFormat Format { get; set; } + + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_EncryptFile_Property_KeyFormat_Name))] + [LocalizedDescription(nameof(Resources.Activity_EncryptFile_Property_KeyFormat_Description))] + [DefaultValue(KeyBytesFormat.Encoded)] + public KeyBytesFormat KeyFormat { get; set; } + + [DefaultValue(null)] + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_EncryptFile_Property_Iv_Name))] + [LocalizedDescription(nameof(Resources.Activity_EncryptFile_Property_Iv_Description))] + public InArgument Iv { get; set; } + + [DefaultValue(null)] + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_EncryptFile_Property_KdfIterations_Name))] + [LocalizedDescription(nameof(Resources.Activity_EncryptFile_Property_KdfIterations_Description))] + public InArgument KdfIterations { get; set; } + [LocalizedCategory(nameof(Resources.Input))] [LocalizedDisplayName(nameof(Resources.Activity_EncryptFile_Property_OutputFilePath_Name))] [LocalizedDescription(nameof(Resources.Activity_EncryptFile_Property_OutputFilePath_Description))] @@ -155,6 +179,11 @@ protected override void CacheMetadata(CodeActivityMetadata metadata) { metadata.AddValidationError(new ValidationError(Resources.ChaCha20Poly1305NotSupported, isWarning: true, nameof(Algorithm))); } + + if (Iv != null) + { + metadata.AddValidationError(new ValidationError(Resources.Iv_NonceReuseWarning, isWarning: true, nameof(Iv))); + } } protected override void Execute(CodeActivityContext context) @@ -183,7 +212,7 @@ protected override void Execute(CodeActivityContext context) var encrypted = Algorithm == EncryptionAlgorithm.PGP ? ExecutePgpEncrypt(context, result.Item3) - : CryptographyHelper.EncryptData(Algorithm, File.ReadAllBytes(result.Item3), CryptographyHelper.KeyEncoding(keyEncoding, key, keySecureString)); + : ExecuteSymmetricEncrypt(context, File.ReadAllBytes(result.Item3), keyEncoding, key, keySecureString); WriteEncryptedOutput(context, outputFilePath, encrypted, result); #if ENABLE_DEFAULT_TELEMETRY @@ -225,6 +254,19 @@ private void ValidateSymmetricKeyParams(CodeActivityContext context, out string keyEncoding = EncodingHelpers.KeyEncodingOrString(keyEncoding, keyEncodingString); } + private byte[] ExecuteSymmetricEncrypt(CodeActivityContext context, byte[] inputBytes, Encoding keyEncoding, string key, SecureString keySecureString) + { + var ivString = Iv?.Get(context); + var iterations = KdfIterations?.Get(context) ?? 0; + + return SymmetricInteropHelper.RunSymmetricWithKeyLifecycle( + Algorithm, Format, KeyFormat, keyEncoding, + keyString: key, keySecureString: keySecureString, + ivString: ivString, kdfIterations: iterations, needsIv: true, + dispatch: (k, iv) => SymmetricInteropHelper.DispatchEncrypt( + Algorithm, Format, iterations, k, iv, inputBytes)); + } + private byte[] ExecutePgpEncrypt(CodeActivityContext context, string inputPath) { var publicKeyFilePath = PublicKeyFilePath.Get(context); diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/EncryptText.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/EncryptText.cs index 20b5a1e10..27de87a6e 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/EncryptText.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/EncryptText.cs @@ -57,6 +57,30 @@ public partial class EncryptText : CodeActivity [Browsable(false)] public InArgument KeyEncodingString { get; set; } + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_EncryptText_Property_Format_Name))] + [LocalizedDescription(nameof(Resources.Activity_EncryptText_Property_Format_Description))] + [DefaultValue(SymmetricWireFormat.Classic)] + public SymmetricWireFormat Format { get; set; } + + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_EncryptText_Property_KeyFormat_Name))] + [LocalizedDescription(nameof(Resources.Activity_EncryptText_Property_KeyFormat_Description))] + [DefaultValue(KeyBytesFormat.Encoded)] + public KeyBytesFormat KeyFormat { get; set; } + + [DefaultValue(null)] + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_EncryptText_Property_Iv_Name))] + [LocalizedDescription(nameof(Resources.Activity_EncryptText_Property_Iv_Description))] + public InArgument Iv { get; set; } + + [DefaultValue(null)] + [LocalizedCategory(nameof(Resources.Input))] + [LocalizedDisplayName(nameof(Resources.Activity_EncryptText_Property_KdfIterations_Name))] + [LocalizedDescription(nameof(Resources.Activity_EncryptText_Property_KdfIterations_Description))] + public InArgument KdfIterations { get; set; } + [LocalizedCategory(nameof(Resources.Output))] [LocalizedDisplayName(nameof(Resources.Activity_EncryptText_Property_Result_Name))] [LocalizedDescription(nameof(Resources.Activity_EncryptText_Property_Result_Description))] @@ -124,6 +148,11 @@ protected override void CacheMetadata(CodeActivityMetadata metadata) { metadata.AddValidationError(new ValidationError(Resources.ChaCha20Poly1305NotSupported, isWarning: true, nameof(Algorithm))); } + + if (Iv != null) + { + metadata.AddValidationError(new ValidationError(Resources.Iv_NonceReuseWarning, isWarning: true, nameof(Iv))); + } } protected override string Execute(CodeActivityContext context) @@ -199,19 +228,26 @@ private string ExecuteSymmetricEncrypt(CodeActivityContext context, string input var keySecureString = KeySecureString.Get(context); var keyEncoding = Encoding.Get(context); var keyEncodingString = KeyEncodingString.Get(context); + var ivString = Iv?.Get(context); + var iterations = KdfIterations?.Get(context) ?? 0; if (string.IsNullOrWhiteSpace(key)) { if (keySecureString == null || keySecureString.Length == 0) throw new ArgumentNullException(nameof(Key), Resources.Activity_EncryptText_Property_Key_Name); - key = null; // ensure helper falls back to SecureString + key = null; } if (keyEncoding == null && string.IsNullOrEmpty(keyEncodingString)) throw new ArgumentNullException(Resources.Encoding); keyEncoding = EncodingHelpers.KeyEncodingOrString(keyEncoding, keyEncodingString); - var encrypted = CryptographyHelper.EncryptData(Algorithm, keyEncoding.GetBytes(input), CryptographyHelper.KeyEncoding(keyEncoding, key, keySecureString)); + byte[] encrypted = SymmetricInteropHelper.RunSymmetricWithKeyLifecycle( + Algorithm, Format, KeyFormat, keyEncoding, + keyString: key, keySecureString: keySecureString, + ivString: ivString, kdfIterations: iterations, needsIv: true, + dispatch: (k, iv) => SymmetricInteropHelper.DispatchEncrypt( + Algorithm, Format, iterations, k, iv, keyEncoding.GetBytes(input))); return Convert.ToBase64String(encrypted); } diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptCryptoViewModelBase.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptCryptoViewModelBase.cs index e709bb26d..f1b014979 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptCryptoViewModelBase.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptCryptoViewModelBase.cs @@ -68,6 +68,10 @@ protected DecryptCryptoViewModelBase(IDesignServices services) : base(services) public DesignInArgument PublicKeyFilePath { get; set; } = new DesignInArgument(); public DesignInArgument PublicKeyFile { get; set; } = new DesignInArgument(); + public DesignProperty Format { get; set; } = new DesignProperty(); + public DesignProperty KeyFormat { get; set; } = new DesignProperty(); + public DesignInArgument KdfIterations { get; set; } = new DesignInArgument(); + /// /// Configures Algorithm dropdown, Key, KeySecureString, and KeyEncodingString properties. /// @@ -109,6 +113,43 @@ protected void ConfigureAlgorithmAndKeyProperties(ref int orderIndex) _encodingDataSource.Data = EncodingHelpers.GetAvailableEncodings(); } + /// + /// Configures the third-party-compatibility properties (Format, KeyFormat, KdfIterations). + /// Format is visible by default; the others are hidden until + /// reveals them based on Format/Algorithm. Decrypt has no IV property — the IV is read from + /// the ciphertext stream at decrypt time. + /// + protected void ConfigureInteropProperties(ref int orderIndex) + { + Format.IsPrincipal = false; + Format.IsVisible = true; + Format.OrderIndex = orderIndex++; + Format.Category = Resources.Input; + Format.DataSource = DataSourceHelper.ForEnum( + SymmetricWireFormat.Classic, + SymmetricWireFormat.Owasp2026, + SymmetricWireFormat.Raw, + SymmetricWireFormat.OpenSslEnc); + Format.Widget = new DefaultWidget { Type = ViewModelWidgetType.Dropdown }; + + KeyFormat.IsPrincipal = false; + KeyFormat.IsVisible = false; + KeyFormat.OrderIndex = orderIndex++; + KeyFormat.Category = Resources.Input; + // Encoded is intentionally omitted — the dropdown is visible only when Format = Raw, + // and Raw rejects Encoded at runtime. FormatChanged_Action keeps the underlying value + // in sync (Hex when Raw, Encoded otherwise) so non-Raw runtime validation stays clean. + KeyFormat.DataSource = DataSourceHelper.ForEnum( + KeyBytesFormat.Hex, + KeyBytesFormat.Base64); + KeyFormat.Widget = new DefaultWidget { Type = ViewModelWidgetType.Dropdown }; + + KdfIterations.IsPrincipal = false; + KdfIterations.IsVisible = false; + KdfIterations.OrderIndex = orderIndex++; + KdfIterations.Category = Resources.Input; + } + /// /// Configures ContinueOnError and PGP decrypt properties /// (PrivateKeyFilePath, Passphrase, VerifySignature, PublicKeyFilePath — hidden by default). @@ -216,6 +257,7 @@ protected override void InitializeRules() base.InitializeRules(); Rule(nameof(Algorithm), AlgorithmChanged_Action); Rule(nameof(VerifySignature), VerifySignatureChanged_Action); + Rule(nameof(Format), FormatChanged_Action); } protected override void ManualRegisterDependencies() @@ -223,6 +265,7 @@ protected override void ManualRegisterDependencies() base.ManualRegisterDependencies(); RegisterDependency(Algorithm, nameof(Algorithm.Value), nameof(Algorithm)); RegisterDependency(VerifySignature, nameof(VerifySignature.Value), nameof(VerifySignature)); + RegisterDependency(Format, nameof(Format.Value), nameof(Format)); } private void AlgorithmChanged_Action() @@ -238,6 +281,42 @@ private void AlgorithmChanged_Action() VerifySignature.IsVisible = isPgp; VerifySignature.IsPrincipal = isPgp; ApplyPublicKeyVisibility(); + ApplyInteropVisibility(); + } + + private void FormatChanged_Action() + { + ApplyInteropVisibility(); + // Snap KdfIterations to a concrete value whenever Format changes — avoids the user seeing 0 + // and having to look up what the format ships with. Customizations made before the format + // change are discarded by design (they were tied to the previous format's KDF anyway). + KdfIterations.Value = KdfIterations.IsVisible + ? CryptographyHelper.GetRecommendedIterations(Format.Value) + : 0; + // Snap KeyFormat: Hex when Raw (so the dropdown lands on a valid option), + // Encoded otherwise (so non-Raw runtime validation passes). + KeyFormat.Value = Format.Value == SymmetricWireFormat.Raw + ? KeyBytesFormat.Hex + : KeyBytesFormat.Encoded; + } + + private void ApplyInteropVisibility() + { + bool isPgp = Algorithm.Value == EncryptionAlgorithm.PGP; + bool isRaw = Format.Value == SymmetricWireFormat.Raw; + bool isOwasp2026OrOpenSsl = Format.Value == SymmetricWireFormat.Owasp2026 || Format.Value == SymmetricWireFormat.OpenSslEnc; + + Format.IsVisible = !isPgp; + KeyFormat.IsVisible = !isPgp && isRaw; + KdfIterations.IsVisible = !isPgp && isOwasp2026OrOpenSsl; + + // Surface the underlying KDF in the visible label so the iteration count's effect is unambiguous. + if (KdfIterations.IsVisible) + { + KdfIterations.DisplayName = Format.Value == SymmetricWireFormat.Owasp2026 + ? Resources.Activity_KdfIterations_DisplayName_Pbkdf2Sha1 + : Resources.Activity_KdfIterations_DisplayName_Pbkdf2Sha256; + } } private void VerifySignatureChanged_Action() diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptFileViewModel.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptFileViewModel.cs index 2e66e5ace..9444d59f9 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptFileViewModel.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptFileViewModel.cs @@ -46,6 +46,7 @@ protected override void InitializeModel() orderIndex++; ConfigureAlgorithmAndKeyProperties(ref orderIndex); + ConfigureInteropProperties(ref orderIndex); OutputFilePath.IsPrincipal = false; OutputFilePath.IsVisible = true; diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptTextViewModel.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptTextViewModel.cs index 91b682952..e6226bd77 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptTextViewModel.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/DecryptTextViewModel.cs @@ -35,6 +35,7 @@ protected override void InitializeModel() Input.Category = Resources.Input; ConfigureAlgorithmAndKeyProperties(ref orderIndex); + ConfigureInteropProperties(ref orderIndex); Result.IsPrincipal = false; Result.OrderIndex = orderIndex++; diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptCryptoViewModelBase.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptCryptoViewModelBase.cs index 23c00957a..1f209eb14 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptCryptoViewModelBase.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptCryptoViewModelBase.cs @@ -73,6 +73,11 @@ protected EncryptCryptoViewModelBase(IDesignServices services) : base(services) public DesignInArgument Passphrase { get; set; } = new DesignInArgument(); public DesignInArgument PassphraseSecureString { get; set; } = new DesignInArgument(); + public DesignProperty Format { get; set; } = new DesignProperty(); + public DesignProperty KeyFormat { get; set; } = new DesignProperty(); + public DesignInArgument Iv { get; set; } = new DesignInArgument(); + public DesignInArgument KdfIterations { get; set; } = new DesignInArgument(); + /// /// Configures Algorithm dropdown, DeprecatedWarning, Key, KeySecureString, /// and KeyEncodingString properties. @@ -124,6 +129,47 @@ protected void ConfigureAlgorithmAndKeyProperties(ref int orderIndex) _encodingDataSource.Data = EncodingHelpers.GetAvailableEncodings(); } + /// + /// Configures the third-party-compatibility properties (Format, KeyFormat, Iv, KdfIterations). + /// Format is visible by default; the others are hidden until + /// reveals them based on Format/Algorithm. + /// + protected void ConfigureInteropProperties(ref int orderIndex) + { + Format.IsPrincipal = false; + Format.IsVisible = true; + Format.OrderIndex = orderIndex++; + Format.Category = Resources.Input; + Format.DataSource = DataSourceHelper.ForEnum( + SymmetricWireFormat.Classic, + SymmetricWireFormat.Owasp2026, + SymmetricWireFormat.Raw, + SymmetricWireFormat.OpenSslEnc); + Format.Widget = new DefaultWidget { Type = ViewModelWidgetType.Dropdown }; + + KeyFormat.IsPrincipal = false; + KeyFormat.IsVisible = false; + KeyFormat.OrderIndex = orderIndex++; + KeyFormat.Category = Resources.Input; + // Encoded is intentionally omitted — the dropdown is visible only when Format = Raw, + // and Raw rejects Encoded at runtime. FormatChanged_Action keeps the underlying value + // in sync (Hex when Raw, Encoded otherwise) so non-Raw runtime validation stays clean. + KeyFormat.DataSource = DataSourceHelper.ForEnum( + KeyBytesFormat.Hex, + KeyBytesFormat.Base64); + KeyFormat.Widget = new DefaultWidget { Type = ViewModelWidgetType.Dropdown }; + + Iv.IsPrincipal = false; + Iv.IsVisible = false; + Iv.OrderIndex = orderIndex++; + Iv.Category = Resources.Input; + + KdfIterations.IsPrincipal = false; + KdfIterations.IsVisible = false; + KdfIterations.OrderIndex = orderIndex++; + KdfIterations.Category = Resources.Input; + } + /// /// Configures ContinueOnError and PGP encrypt properties /// (PublicKeyFilePath, SignData, PrivateKeyFilePath, Passphrase — hidden by default). @@ -230,6 +276,7 @@ protected override void InitializeRules() base.InitializeRules(); Rule(nameof(Algorithm), AlgorithmChanged_Action); Rule(nameof(SignData), SignDataChanged_Action); + Rule(nameof(Format), FormatChanged_Action); } protected override void ManualRegisterDependencies() @@ -237,6 +284,7 @@ protected override void ManualRegisterDependencies() base.ManualRegisterDependencies(); RegisterDependency(Algorithm, nameof(Algorithm.Value), nameof(Algorithm)); RegisterDependency(SignData, nameof(SignData.Value), nameof(SignData)); + RegisterDependency(Format, nameof(Format.Value), nameof(Format)); } private void AlgorithmChanged_Action() @@ -254,6 +302,43 @@ private void AlgorithmChanged_Action() PrivateKeyFilePath.IsRequired = isPgp && SignData.Value; PrivateKeyFilePath.IsPrincipal = isPgp && SignData.Value; ApplyPassphraseVisibility(); + ApplyInteropVisibility(); + } + + private void FormatChanged_Action() + { + ApplyInteropVisibility(); + // Snap KdfIterations to a concrete value whenever Format changes — avoids the user seeing 0 + // and having to look up what the format ships with. Customizations made before the format + // change are discarded by design (they were tied to the previous format's KDF anyway). + KdfIterations.Value = KdfIterations.IsVisible + ? CryptographyHelper.GetRecommendedIterations(Format.Value) + : 0; + // Snap KeyFormat: Hex when Raw (so the dropdown lands on a valid option), + // Encoded otherwise (so non-Raw runtime validation passes). + KeyFormat.Value = Format.Value == SymmetricWireFormat.Raw + ? KeyBytesFormat.Hex + : KeyBytesFormat.Encoded; + } + + private void ApplyInteropVisibility() + { + bool isPgp = Algorithm.Value == EncryptionAlgorithm.PGP; + bool isRaw = Format.Value == SymmetricWireFormat.Raw; + bool isOwasp2026OrOpenSsl = Format.Value == SymmetricWireFormat.Owasp2026 || Format.Value == SymmetricWireFormat.OpenSslEnc; + + Format.IsVisible = !isPgp; + KeyFormat.IsVisible = !isPgp && isRaw; + Iv.IsVisible = !isPgp && isRaw; + KdfIterations.IsVisible = !isPgp && isOwasp2026OrOpenSsl; + + // Surface the underlying KDF in the visible label so the iteration count's effect is unambiguous. + if (KdfIterations.IsVisible) + { + KdfIterations.DisplayName = Format.Value == SymmetricWireFormat.Owasp2026 + ? Resources.Activity_KdfIterations_DisplayName_Pbkdf2Sha1 + : Resources.Activity_KdfIterations_DisplayName_Pbkdf2Sha256; + } } private void UpdateDeprecatedAlgorithmWarning() diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptFileViewModel.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptFileViewModel.cs index 82ab915fe..ff0943ad9 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptFileViewModel.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptFileViewModel.cs @@ -46,6 +46,7 @@ protected override void InitializeModel() orderIndex++; ConfigureAlgorithmAndKeyProperties(ref orderIndex); + ConfigureInteropProperties(ref orderIndex); OutputFilePath.IsPrincipal = false; OutputFilePath.IsVisible = true; diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptTextViewModel.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptTextViewModel.cs index c0a88f523..c2ed94894 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptTextViewModel.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/EncryptTextViewModel.cs @@ -35,6 +35,7 @@ protected override void InitializeModel() Input.Category = Resources.Input; ConfigureAlgorithmAndKeyProperties(ref orderIndex); + ConfigureInteropProperties(ref orderIndex); Result.IsPrincipal = false; Result.OrderIndex = orderIndex++; diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/PgpClearsignFileViewModel.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/PgpClearSignFileViewModel.cs similarity index 78% rename from Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/PgpClearsignFileViewModel.cs rename to Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/PgpClearSignFileViewModel.cs index d1fac23c9..d290c687d 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/PgpClearsignFileViewModel.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/NetCore/ViewModels/PgpClearSignFileViewModel.cs @@ -7,8 +7,8 @@ namespace UiPath.Cryptography.Activities { - [ViewModelClass(typeof(PgpClearsignFileViewModel))] - public partial class PgpClearsignFile + [ViewModelClass(typeof(PgpClearSignFileViewModel))] + public partial class PgpClearSignFile { } } @@ -16,9 +16,9 @@ public partial class PgpClearsignFile namespace UiPath.Cryptography.Activities.NetCore.ViewModels { [ExcludeFromCodeCoverage] - public class PgpClearsignFileViewModel : PgpSignViewModelBase + public class PgpClearSignFileViewModel : PgpSignViewModelBase { - public PgpClearsignFileViewModel(IDesignServices services) : base(services) + public PgpClearSignFileViewModel(IDesignServices services) : base(services) { } diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/PgpClearsignFile.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/PgpClearSignFile.cs similarity index 82% rename from Activities/Cryptography/UiPath.Cryptography.Activities/PgpClearsignFile.cs rename to Activities/Cryptography/UiPath.Cryptography.Activities/PgpClearSignFile.cs index 27f0b77e4..5b3f87e39 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/PgpClearsignFile.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/PgpClearSignFile.cs @@ -18,70 +18,70 @@ namespace UiPath.Cryptography.Activities { - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Description))] - public partial class PgpClearsignFile : UiPath.Shared.Activities.AsyncTaskCodeActivity + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Description))] + public partial class PgpClearSignFile : UiPath.Shared.Activities.AsyncTaskCodeActivity { private const string ClearSigned = "_ClearSigned"; [LocalizedCategory(nameof(Resources.Input))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_InputFilePath_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_InputFilePath_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_InputFilePath_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_InputFilePath_Description))] public InArgument InputFilePath { get; set; } [Browsable(false)] [DefaultValue(null)] [LocalizedCategory(nameof(Resources.Input))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_InputFile_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_InputFile_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_InputFile_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_InputFile_Description))] public InArgument InputFile { get; set; } [LocalizedCategory(nameof(Resources.Input))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Description))] public InArgument PrivateKeyFilePath { get; set; } [Browsable(false)] [DefaultValue(null)] [LocalizedCategory(nameof(Resources.Input))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_PrivateKeyFile_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_PrivateKeyFile_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_PrivateKeyFile_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_PrivateKeyFile_Description))] public InArgument PrivateKeyFile { get; set; } [LocalizedCategory(nameof(Resources.Input))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_Passphrase_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_Passphrase_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_Passphrase_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_Passphrase_Description))] public InArgument Passphrase { get; set; } [DefaultValue(null)] [LocalizedCategory(nameof(Resources.Input))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_PassphraseSecureString_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_PassphraseSecureString_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_PassphraseSecureString_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_PassphraseSecureString_Description))] public InArgument PassphraseSecureString { get; set; } [RequiredArgument] [LocalizedCategory(nameof(Resources.Category_Options_Name))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_OutputFilePath_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_OutputFilePath_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_OutputFilePath_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_OutputFilePath_Description))] public InArgument OutputFilePath { get; set; } [DefaultValue(false)] [LocalizedCategory(nameof(Resources.Category_Options_Name))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_Overwrite_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_Overwrite_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_Overwrite_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_Overwrite_Description))] public bool Overwrite { get; set; } [DefaultValue(null)] [LocalizedCategory(nameof(Resources.Category_Options_Name))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_ContinueOnError_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_ContinueOnError_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_ContinueOnError_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_ContinueOnError_Description))] public InArgument ContinueOnError { get; set; } [Browsable(false)] [DefaultValue(null)] [LocalizedCategory(nameof(Resources.Output))] - [LocalizedDisplayName(nameof(Resources.Activity_PgpClearsignFile_Property_ClearSignedFile_Name))] - [LocalizedDescription(nameof(Resources.Activity_PgpClearsignFile_Property_ClearSignedFile_Description))] + [LocalizedDisplayName(nameof(Resources.Activity_PgpClearSignFile_Property_ClearSignedFile_Name))] + [LocalizedDescription(nameof(Resources.Activity_PgpClearSignFile_Property_ClearSignedFile_Description))] public OutArgument ClearSignedFile { get; set; } protected override async Task> ExecuteAsync( @@ -93,17 +93,17 @@ protected override async Task> ExecuteAsync( { var inputPath = await PgpFileResolver.ResolveAsync( InputFilePath.Get(context), InputFile.Get(context), - nameof(InputFilePath), Resources.Activity_PgpClearsignFile_Property_InputFilePath_Name, + nameof(InputFilePath), Resources.Activity_PgpClearSignFile_Property_InputFilePath_Name, cancellationToken); var privateKeyPath = await PgpFileResolver.ResolveAsync( PrivateKeyFilePath.Get(context), PrivateKeyFile.Get(context), - nameof(PrivateKeyFilePath), Resources.Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Name, + nameof(PrivateKeyFilePath), Resources.Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Name, cancellationToken); var passphraseString = PgpFileResolver.ResolvePassphrase( Passphrase.Get(context), PassphraseSecureString.Get(context), - nameof(Passphrase), Resources.Activity_PgpClearsignFile_Property_Passphrase_Name); + nameof(Passphrase), Resources.Activity_PgpClearSignFile_Property_Passphrase_Name); var outputPath = OutputFilePath.Get(context); diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/Properties/UiPath.Cryptography.Activities.Designer.cs b/Activities/Cryptography/UiPath.Cryptography.Activities/Properties/UiPath.Cryptography.Activities.Designer.cs index 3f736de50..397415a65 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/Properties/UiPath.Cryptography.Activities.Designer.cs +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/Properties/UiPath.Cryptography.Activities.Designer.cs @@ -150,6 +150,24 @@ public static string Activity_DecryptFile_Property_FileInputModeSwitch_Name { } } + /// + /// Looks up a localized string similar to Input byte layout and key-derivation strategy. Must match the format that produced the ciphertext.. + /// + public static string Activity_DecryptFile_Property_Format_Description { + get { + return ResourceManager.GetString("Activity_DecryptFile_Property_Format_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wire format. + /// + public static string Activity_DecryptFile_Property_Format_Name { + get { + return ResourceManager.GetString("Activity_DecryptFile_Property_Format_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The file to be decrypted. /// @@ -186,6 +204,24 @@ public static string Activity_DecryptFile_Property_InputFilePath_Name { } } + /// + /// Looks up a localized string similar to PBKDF2 iteration count used to derive the key. Must match the value used at encryption time.. + /// + public static string Activity_DecryptFile_Property_KdfIterations_Description { + get { + return ResourceManager.GetString("Activity_DecryptFile_Property_KdfIterations_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to KDF iterations. + /// + public static string Activity_DecryptFile_Property_KdfIterations_Name { + get { + return ResourceManager.GetString("Activity_DecryptFile_Property_KdfIterations_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The key that you want to use to decrypt the specified file.. /// @@ -240,6 +276,24 @@ public static string Activity_DecryptFile_Property_KeyEncodingString_Name { } } + /// + /// Looks up a localized string similar to How the Key string converts to bytes.. + /// + public static string Activity_DecryptFile_Property_KeyFormat_Description { + get { + return ResourceManager.GetString("Activity_DecryptFile_Property_KeyFormat_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Key bytes format. + /// + public static string Activity_DecryptFile_Property_KeyFormat_Name { + get { + return ResourceManager.GetString("Activity_DecryptFile_Property_KeyFormat_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The switch between Key and Secure String Key. /// @@ -510,6 +564,24 @@ public static string Activity_DecryptText_Property_Encoding_Name { } } + /// + /// Looks up a localized string similar to Input byte layout and key-derivation strategy. Must match the format that produced the ciphertext. See docs/symmetric-wire-format.md.. + /// + public static string Activity_DecryptText_Property_Format_Description { + get { + return ResourceManager.GetString("Activity_DecryptText_Property_Format_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wire format. + /// + public static string Activity_DecryptText_Property_Format_Name { + get { + return ResourceManager.GetString("Activity_DecryptText_Property_Format_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The text that you want to decrypt.. /// @@ -528,6 +600,24 @@ public static string Activity_DecryptText_Property_Input_Name { } } + /// + /// Looks up a localized string similar to PBKDF2 iteration count used to derive the key. Must match the value used at encryption time. 0 (default) selects the OWASP-recommended default for the chosen format.. + /// + public static string Activity_DecryptText_Property_KdfIterations_Description { + get { + return ResourceManager.GetString("Activity_DecryptText_Property_KdfIterations_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to KDF iterations. + /// + public static string Activity_DecryptText_Property_KdfIterations_Name { + get { + return ResourceManager.GetString("Activity_DecryptText_Property_KdfIterations_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The key that you want to use to decrypt the specified file.. /// @@ -564,6 +654,24 @@ public static string Activity_DecryptText_Property_KeyEncodingString_Name { } } + /// + /// Looks up a localized string similar to How the Key string converts to bytes. Encoded = password text via Encoding. Hex/Base64 = literal raw bytes (required for Raw format).. + /// + public static string Activity_DecryptText_Property_KeyFormat_Description { + get { + return ResourceManager.GetString("Activity_DecryptText_Property_KeyFormat_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Key bytes format. + /// + public static string Activity_DecryptText_Property_KeyFormat_Name { + get { + return ResourceManager.GetString("Activity_DecryptText_Property_KeyFormat_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The switch between Key and Secure String Key. /// @@ -825,6 +933,24 @@ public static string Activity_EncryptFile_Property_FileInputModeSwitch_Name { } } + /// + /// Looks up a localized string similar to Output byte layout and key-derivation strategy. See docs/symmetric-wire-format.md.. + /// + public static string Activity_EncryptFile_Property_Format_Description { + get { + return ResourceManager.GetString("Activity_EncryptFile_Property_Format_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wire format. + /// + public static string Activity_EncryptFile_Property_Format_Name { + get { + return ResourceManager.GetString("Activity_EncryptFile_Property_Format_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The file to be encrypted. /// @@ -861,6 +987,42 @@ public static string Activity_EncryptFile_Property_InputFilePath_Name { } } + /// + /// Looks up a localized string similar to Optional initialization vector for Raw format. If empty, a random IV is generated. Interpreted via Key bytes format (Hex or Base64).. + /// + public static string Activity_EncryptFile_Property_Iv_Description { + get { + return ResourceManager.GetString("Activity_EncryptFile_Property_Iv_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to IV (Raw only). + /// + public static string Activity_EncryptFile_Property_Iv_Name { + get { + return ResourceManager.GetString("Activity_EncryptFile_Property_Iv_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to PBKDF2 iteration count for Owasp2026 and OpenSslEnc formats. 0 (default) selects the value the format ships with. Minimum 1,000 when set.. + /// + public static string Activity_EncryptFile_Property_KdfIterations_Description { + get { + return ResourceManager.GetString("Activity_EncryptFile_Property_KdfIterations_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to KDF iterations. + /// + public static string Activity_EncryptFile_Property_KdfIterations_Name { + get { + return ResourceManager.GetString("Activity_EncryptFile_Property_KdfIterations_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The key that you want to use to encrypt the specified file.. /// @@ -915,6 +1077,24 @@ public static string Activity_EncryptFile_Property_KeyEncodingString_Name { } } + /// + /// Looks up a localized string similar to How the Key (and Iv, for Raw format) strings convert to bytes.. + /// + public static string Activity_EncryptFile_Property_KeyFormat_Description { + get { + return ResourceManager.GetString("Activity_EncryptFile_Property_KeyFormat_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Key bytes format. + /// + public static string Activity_EncryptFile_Property_KeyFormat_Name { + get { + return ResourceManager.GetString("Activity_EncryptFile_Property_KeyFormat_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The switch between Key and Secure String Key. /// @@ -1185,6 +1365,24 @@ public static string Activity_EncryptText_Property_Encoding_Name { } } + /// + /// Looks up a localized string similar to Output byte layout and key-derivation strategy. See docs/symmetric-wire-format.md.. + /// + public static string Activity_EncryptText_Property_Format_Description { + get { + return ResourceManager.GetString("Activity_EncryptText_Property_Format_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Wire format. + /// + public static string Activity_EncryptText_Property_Format_Name { + get { + return ResourceManager.GetString("Activity_EncryptText_Property_Format_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The text that you want to encrypt.. /// @@ -1203,6 +1401,42 @@ public static string Activity_EncryptText_Property_Input_Name { } } + /// + /// Looks up a localized string similar to Optional initialization vector for Raw format. If empty, a random IV is generated. Interpreted via Key bytes format (Hex or Base64). Must match the algorithm's IV size (16 bytes for AES/Rijndael, 12 for AESGCM/ChaCha20Poly1305, 8 for DES/TripleDES/RC2).. + /// + public static string Activity_EncryptText_Property_Iv_Description { + get { + return ResourceManager.GetString("Activity_EncryptText_Property_Iv_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to IV (Raw only). + /// + public static string Activity_EncryptText_Property_Iv_Name { + get { + return ResourceManager.GetString("Activity_EncryptText_Property_Iv_Name", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to PBKDF2 iteration count, used only for Owasp2026 and OpenSslEnc formats. 0 (default) selects the value the format ships with: 1,300,000 for Owasp2026 (PBKDF2-HMAC-SHA1), 600,000 for OpenSslEnc (PBKDF2-HMAC-SHA256). Minimum 1,000 when set explicitly. Note: the iteration count is not stored in the wire format — encrypt and decrypt sides must use matching values.. + /// + public static string Activity_EncryptText_Property_KdfIterations_Description { + get { + return ResourceManager.GetString("Activity_EncryptText_Property_KdfIterations_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to KDF iterations. + /// + public static string Activity_EncryptText_Property_KdfIterations_Name { + get { + return ResourceManager.GetString("Activity_EncryptText_Property_KdfIterations_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The key that you want to use to encrypt the specified file.. /// @@ -1239,6 +1473,24 @@ public static string Activity_EncryptText_Property_KeyEncodingString_Name { } } + /// + /// Looks up a localized string similar to How the Key (and Iv, for Raw format) strings convert to bytes. Encoded (default) = password text via Encoding. Hex/Base64 = literal raw bytes (required for Raw format).. + /// + public static string Activity_EncryptText_Property_KeyFormat_Description { + get { + return ResourceManager.GetString("Activity_EncryptText_Property_KeyFormat_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Key bytes format. + /// + public static string Activity_EncryptText_Property_KeyFormat_Name { + get { + return ResourceManager.GetString("Activity_EncryptText_Property_KeyFormat_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The switch between Key and Secure String Key. /// @@ -2034,198 +2286,198 @@ public static string Activity_KeyedHashText_Property_Result_Name { /// /// Looks up a localized string similar to Creates a PGP clear-text signature of a file using a private key.. /// - public static string Activity_PgpClearsignFile_Description { + public static string Activity_PgpClearSignFile_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Description", resourceCulture); } } /// - /// Looks up a localized string similar to PGP Clearsign File. + /// Looks up a localized string similar to PGP ClearSign File. /// - public static string Activity_PgpClearsignFile_Name { + public static string Activity_PgpClearSignFile_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Name", resourceCulture); } } /// /// Looks up a localized string similar to The clearsigned file as a file resource.. /// - public static string Activity_PgpClearsignFile_Property_ClearSignedFile_Description { + public static string Activity_PgpClearSignFile_Property_ClearSignedFile_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_ClearSignedFile_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_ClearSignedFile_Description", resourceCulture); } } /// - /// Looks up a localized string similar to Clearsigned file. + /// Looks up a localized string similar to ClearSigned file. /// - public static string Activity_PgpClearsignFile_Property_ClearSignedFile_Name { + public static string Activity_PgpClearSignFile_Property_ClearSignedFile_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_ClearSignedFile_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_ClearSignedFile_Name", resourceCulture); } } /// /// Looks up a localized string similar to Specifies if the automation should continue even when the activity throws an error.. /// - public static string Activity_PgpClearsignFile_Property_ContinueOnError_Description { + public static string Activity_PgpClearSignFile_Property_ContinueOnError_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_ContinueOnError_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_ContinueOnError_Description", resourceCulture); } } /// /// Looks up a localized string similar to Continue on error. /// - public static string Activity_PgpClearsignFile_Property_ContinueOnError_Name { + public static string Activity_PgpClearSignFile_Property_ContinueOnError_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_ContinueOnError_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_ContinueOnError_Name", resourceCulture); } } /// /// Looks up a localized string similar to The resource representing the file to be signed.. /// - public static string Activity_PgpClearsignFile_Property_InputFile_Description { + public static string Activity_PgpClearSignFile_Property_InputFile_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_InputFile_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_InputFile_Description", resourceCulture); } } /// /// Looks up a localized string similar to Input file. /// - public static string Activity_PgpClearsignFile_Property_InputFile_Name { + public static string Activity_PgpClearSignFile_Property_InputFile_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_InputFile_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_InputFile_Name", resourceCulture); } } /// /// Looks up a localized string similar to The path to the file that you want to clearsign.. /// - public static string Activity_PgpClearsignFile_Property_InputFilePath_Description { + public static string Activity_PgpClearSignFile_Property_InputFilePath_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_InputFilePath_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_InputFilePath_Description", resourceCulture); } } /// /// Looks up a localized string similar to Input file path. /// - public static string Activity_PgpClearsignFile_Property_InputFilePath_Name { + public static string Activity_PgpClearSignFile_Property_InputFilePath_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_InputFilePath_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_InputFilePath_Name", resourceCulture); } } /// /// Looks up a localized string similar to The full path, including the file name and extension, where the clearsigned file will be saved.. /// - public static string Activity_PgpClearsignFile_Property_OutputFilePath_Description { + public static string Activity_PgpClearSignFile_Property_OutputFilePath_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_OutputFilePath_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_OutputFilePath_Description", resourceCulture); } } /// /// Looks up a localized string similar to Output file path. /// - public static string Activity_PgpClearsignFile_Property_OutputFilePath_Name { + public static string Activity_PgpClearSignFile_Property_OutputFilePath_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_OutputFilePath_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_OutputFilePath_Name", resourceCulture); } } /// /// Looks up a localized string similar to If a file already exists at the output path, selecting this overwrites it.. /// - public static string Activity_PgpClearsignFile_Property_Overwrite_Description { + public static string Activity_PgpClearSignFile_Property_Overwrite_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_Overwrite_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_Overwrite_Description", resourceCulture); } } /// /// Looks up a localized string similar to Overwrite. /// - public static string Activity_PgpClearsignFile_Property_Overwrite_Name { + public static string Activity_PgpClearSignFile_Property_Overwrite_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_Overwrite_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_Overwrite_Name", resourceCulture); } } /// /// Looks up a localized string similar to The passphrase that unlocks your own private key for signing.. /// - public static string Activity_PgpClearsignFile_Property_Passphrase_Description { + public static string Activity_PgpClearSignFile_Property_Passphrase_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_Passphrase_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_Passphrase_Description", resourceCulture); } } /// /// Looks up a localized string similar to Passphrase. /// - public static string Activity_PgpClearsignFile_Property_Passphrase_Name { + public static string Activity_PgpClearSignFile_Property_Passphrase_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_Passphrase_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_Passphrase_Name", resourceCulture); } } /// /// Looks up a localized string similar to The passphrase, as a secure string, that unlocks your own private key for signing.. /// - public static string Activity_PgpClearsignFile_Property_PassphraseSecureString_Description { + public static string Activity_PgpClearSignFile_Property_PassphraseSecureString_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_PassphraseSecureString_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_PassphraseSecureString_Description", resourceCulture); } } /// /// Looks up a localized string similar to Passphrase (secure). /// - public static string Activity_PgpClearsignFile_Property_PassphraseSecureString_Name { + public static string Activity_PgpClearSignFile_Property_PassphraseSecureString_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_PassphraseSecureString_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_PassphraseSecureString_Name", resourceCulture); } } /// /// Looks up a localized string similar to Your own PGP private key, supplied as a file resource. Used to clearsign the data.. /// - public static string Activity_PgpClearsignFile_Property_PrivateKeyFile_Description { + public static string Activity_PgpClearSignFile_Property_PrivateKeyFile_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_PrivateKeyFile_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_PrivateKeyFile_Description", resourceCulture); } } /// /// Looks up a localized string similar to Private key file. /// - public static string Activity_PgpClearsignFile_Property_PrivateKeyFile_Name { + public static string Activity_PgpClearSignFile_Property_PrivateKeyFile_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_PrivateKeyFile_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_PrivateKeyFile_Name", resourceCulture); } } /// /// Looks up a localized string similar to The path to your own PGP private key file, used to clearsign the data.. /// - public static string Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Description { + public static string Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Description { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Description", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Description", resourceCulture); } } /// /// Looks up a localized string similar to Private key file path. /// - public static string Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Name { + public static string Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Name { get { - return ResourceManager.GetString("Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Name", resourceCulture); + return ResourceManager.GetString("Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Name", resourceCulture); } } @@ -2689,7 +2941,7 @@ public static string Activity_PgpVerify_Property_ContinueOnError_Name { } /// - /// Looks up a localized string similar to The file resource that contains the signed content to verify. Required for 'Signed File (Binary)' and 'Clearsigned File (Text)' modes.. + /// Looks up a localized string similar to The file resource that contains the signed content to verify. Required for 'Signed File (Binary)' and 'ClearSigned File (Text)' modes.. /// public static string Activity_PgpVerify_Property_InputFile_Description { get { @@ -2707,7 +2959,7 @@ public static string Activity_PgpVerify_Property_InputFile_Name { } /// - /// Looks up a localized string similar to The path to the signed file to verify. Required for 'Signed File (Binary)' and 'Clearsigned File (Text)' modes.. + /// Looks up a localized string similar to The path to the signed file to verify. Required for 'Signed File (Binary)' and 'ClearSigned File (Text)' modes.. /// public static string Activity_PgpVerify_Property_InputFilePath_Description { get { @@ -2725,7 +2977,7 @@ public static string Activity_PgpVerify_Property_InputFilePath_Name { } /// - /// Looks up a localized string similar to Select the type of verification. 'Signed File (Binary)' verifies a binary-signed file. 'Clearsigned File (Text)' verifies a text file with an embedded signature. 'Validate Public Key' checks that a file contains a valid PGP public key.. + /// Looks up a localized string similar to Select the type of verification. 'Signed File (Binary)' verifies a binary-signed file. 'ClearSigned File (Text)' verifies a text file with an embedded signature. 'Validate Public Key' checks that a file contains a valid PGP public key.. /// public static string Activity_PgpVerify_Property_Mode_Description { get { @@ -2832,6 +3084,24 @@ public static string Category_Principal_Name { } } + /// + /// Looks up a localized string similar to ChaCha20-Poly1305 is not supported on this platform. Use AES-GCM instead, or run on a platform that provides ChaCha20-Poly1305 (Linux with OpenSSL, or Windows 10 1809 / Server 2019 or later).. + /// + public static string ChaCha20Poly1305NotSupported { + get { + return ResourceManager.GetString("ChaCha20Poly1305NotSupported", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to An explicit IV is supplied. Reusing the same (Key, IV) pair across encryptions destroys confidentiality and lets an attacker recover the AEAD authentication key and forge messages. Ensure each (Key, IV) pair is used at most once, or leave IV empty. + /// + public static string Iv_NonceReuseWarning { + get { + return ResourceManager.GetString("Iv_NonceReuseWarning", resourceCulture); + } + } + /// /// Looks up a localized string similar to Common. /// @@ -2912,16 +3182,7 @@ public static string FipsComplianceWarning { return ResourceManager.GetString("FipsComplianceWarning", resourceCulture); } } - - /// - /// Looks up a localized string similar to ChaCha20-Poly1305 is not supported on this platform. Use AES-GCM instead, or run on a platform that provides ChaCha20-Poly1305 (Linux with OpenSSL, or Windows 10 1809 / Server 2019 or later).. - /// - public static string ChaCha20Poly1305NotSupported { - get { - return ResourceManager.GetString("ChaCha20Poly1305NotSupported", resourceCulture); - } - } - + /// /// Looks up a localized string similar to A cryptographic operation has failed. Please make sure you use the same algorithm and key for both encryption and decryption operations.. /// @@ -3173,5 +3434,12 @@ public static string RsaKeySize_Rsa4096 { return ResourceManager.GetString("RsaKeySize_Rsa4096", resourceCulture); } } + + public static string Activity_KdfIterations_DisplayName_Pbkdf2Sha1 { + get { return ResourceManager.GetString("Activity_KdfIterations_DisplayName_Pbkdf2Sha1", resourceCulture); } + } + public static string Activity_KdfIterations_DisplayName_Pbkdf2Sha256 { + get { return ResourceManager.GetString("Activity_KdfIterations_DisplayName_Pbkdf2Sha256", resourceCulture); } + } } } diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/Properties/UiPath.Cryptography.Activities.resx b/Activities/Cryptography/UiPath.Cryptography.Activities/Properties/UiPath.Cryptography.Activities.resx index 096e27544..65dc8aab7 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/Properties/UiPath.Cryptography.Activities.resx +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/Properties/UiPath.Cryptography.Activities.resx @@ -123,6 +123,9 @@ ChaCha20-Poly1305 is not supported on this platform. Use AES-GCM instead, or run on a platform that provides ChaCha20-Poly1305 (Linux with OpenSSL, or Windows 10 1809 / Server 2019 or later). + + An explicit IV is supplied. Reusing the same (Key, IV) pair across encryptions destroys confidentiality and — under AEAD modes (AES-GCM, ChaCha20-Poly1305) — additionally lets an attacker recover the authentication key and forge arbitrary messages under that key. Ensure each (Key, IV) pair is used at most once, or leave IV empty so a fresh random IV is generated per call. + Input @@ -351,7 +354,7 @@ Property name - The key that you want to use to decrypt the specified file. + The key that you want to use to decrypt the specified text. Property description @@ -519,7 +522,7 @@ Property name - The key that you want to use to encrypt the specified file. + The key that you want to use to encrypt the specified text. Property description @@ -1277,91 +1280,91 @@ The signed file as a file resource. Property description - - PGP Clearsign File + + PGP ClearSign File Activity name - + Creates a PGP clear-text signature of a file using a private key. Activity description - + Input file path Property name - + The path to the file that you want to clearsign. Property description - + Input file Property name - + The resource representing the file to be signed. Property description - + Private key file path Property name - + The path to your own PGP private key file, used to clearsign the data. Property description - + Private key file Property name - + Your own PGP private key, supplied as a file resource. Used to clearsign the data. Property description - + Passphrase Property name - + Passphrase (secure) Property name - + The passphrase, as a secure string, that unlocks your own private key for signing. Property description - + The passphrase that unlocks your own private key for signing. Property description - + Output file path Property name - + The full path, including the file name and extension, where the clearsigned file will be saved. Property description - + Overwrite Property name - + If a file already exists at the output path, selecting this overwrites it. Property description - + Continue on error Property name - + Specifies if the automation should continue even when the activity throws an error. Property description - - Clearsigned file + + ClearSigned file Property name - + The clearsigned file as a file resource. Property description @@ -1378,7 +1381,7 @@ Property name - Select the type of verification. 'Signed File (Binary)' verifies a binary-signed file. 'Clearsigned File (Text)' verifies a text file with an embedded signature. 'Validate Public Key' checks that a file contains a valid PGP public key. + Select the type of verification. 'Signed File (Binary)' verifies a binary-signed file. 'ClearSigned File (Text)' verifies a text file with an embedded signature. 'Validate Public Key' checks that a file contains a valid PGP public key. Property description @@ -1386,7 +1389,7 @@ Property name - The path to the signed file to verify. Required for 'Signed File (Binary)' and 'Clearsigned File (Text)' modes. + The path to the signed file to verify. Required for 'Signed File (Binary)' and 'ClearSigned File (Text)' modes. Property description @@ -1402,7 +1405,7 @@ Property name - The file resource that contains the signed content to verify. Required for 'Signed File (Binary)' and 'Clearsigned File (Text)' modes. + The file resource that contains the signed content to verify. Required for 'Signed File (Binary)' and 'ClearSigned File (Text)' modes. Property description @@ -1441,4 +1444,133 @@ 4096-bit RSA + + + + Wire format + Property name + + + Output byte layout and key-derivation strategy. See docs/symmetric-wire-format.md. + Property description + + + Key bytes format + Property name + + + Encoding of the Key (and IV) string: Hex (e.g. 64 chars for an AES-256 key) or Base64. + Property description + + + IV + Property name + + + Initialization vector for Raw format. Provide it in the same encoding selected in the "Key bytes format" dropdown above (Hex or Base64). Leave empty to auto-generate a random IV. Length must match the algorithm's IV size (16 bytes for AES/Rijndael, 12 for AESGCM/ChaCha20Poly1305, 8 for DES/TripleDES/RC2). + Property description + + + KDF iterations + Property name + + + PBKDF2 iteration count, used only for Owasp2026 and OpenSslEnc formats. Pre-filled with the value the format ships with: 1,300,000 for Owasp2026 (PBKDF2-HMAC-SHA1), 600,000 for OpenSslEnc (PBKDF2-HMAC-SHA256). Override only when you need to interop with a peer using a different count. Minimum 1,000. The iteration count is not stored in the wire format — encrypt and decrypt sides must use matching values. + Property description + + + + Wire format + Property name + + + Input byte layout and key-derivation strategy. Must match the format that produced the ciphertext. See docs/symmetric-wire-format.md. + Property description + + + Key bytes format + Property name + + + Encoding of the Key string: Hex (e.g. 64 chars for an AES-256 key) or Base64. + Property description + + + KDF iterations + Property name + + + PBKDF2 iteration count used to derive the key. Must match the value used at encryption time. Pre-filled with the value the chosen format ships with; override to match the producer if it used a different count. + Property description + + + + Wire format + Property name + + + Output byte layout and key-derivation strategy. See docs/symmetric-wire-format.md. + Property description + + + Key bytes format + Property name + + + Encoding of the Key (and IV) string: Hex (e.g. 64 chars for an AES-256 key) or Base64. + Property description + + + IV + Property name + + + Initialization vector for Raw format. Provide it in the same encoding selected in the "Key bytes format" dropdown above (Hex or Base64). Leave empty to auto-generate a random IV. Length must match the algorithm's IV size (16 bytes for AES/Rijndael, 12 for AESGCM/ChaCha20Poly1305, 8 for DES/TripleDES/RC2). + Property description + + + KDF iterations + Property name + + + PBKDF2 iteration count, used only for Owasp2026 and OpenSslEnc formats. Pre-filled with the value the format ships with: 1,300,000 for Owasp2026 (PBKDF2-HMAC-SHA1), 600,000 for OpenSslEnc (PBKDF2-HMAC-SHA256). Override only when you need to interop with a peer using a different count. Minimum 1,000. The iteration count is not stored in the wire format — encrypt and decrypt sides must use matching values. + Property description + + + + Wire format + Property name + + + Input byte layout and key-derivation strategy. Must match the format that produced the ciphertext. See docs/symmetric-wire-format.md. + Property description + + + Key bytes format + Property name + + + Encoding of the Key string: Hex (e.g. 64 chars for an AES-256 key) or Base64. + Property description + + + KDF iterations + Property name + + + PBKDF2 iteration count used to derive the key. Must match the value used at encryption time. Pre-filled with the value the chosen format ships with; override to match the producer if it used a different count. + Property description + + + + + KDF iterations (PBKDF2-HMAC-SHA1) + Dynamic display name when Format = Owasp2026 + + + KDF iterations (PBKDF2-HMAC-SHA256) + Dynamic display name when Format = OpenSslEnc + + \ No newline at end of file diff --git a/Activities/Cryptography/UiPath.Cryptography.Activities/Resources/ActivitiesMetadata.json b/Activities/Cryptography/UiPath.Cryptography.Activities/Resources/ActivitiesMetadata.json index 2fedd3b7a..953de07b0 100644 --- a/Activities/Cryptography/UiPath.Cryptography.Activities/Resources/ActivitiesMetadata.json +++ b/Activities/Cryptography/UiPath.Cryptography.Activities/Resources/ActivitiesMetadata.json @@ -89,6 +89,42 @@ "DisplayNameKey": "Category_Options_Name" } }, + { + "Name": "Format", + "DisplayNameKey": "Activity_DecryptFile_Property_Format_Name", + "TooltipKey": "Activity_DecryptFile_Property_Format_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": true, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "KeyFormat", + "DisplayNameKey": "Activity_DecryptFile_Property_KeyFormat_Name", + "TooltipKey": "Activity_DecryptFile_Property_KeyFormat_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "KdfIterations", + "DisplayNameKey": "Activity_DecryptFile_Property_KdfIterations_Name", + "TooltipKey": "Activity_DecryptFile_Property_KdfIterations_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, { "Name": "Overwrite", "DisplayNameKey": "Activity_DecryptFile_Property_Overwrite_Name", @@ -267,6 +303,42 @@ "DisplayNameKey": "Category_Options_Name" } }, + { + "Name": "Format", + "DisplayNameKey": "Activity_DecryptText_Property_Format_Name", + "TooltipKey": "Activity_DecryptText_Property_Format_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": true, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "KeyFormat", + "DisplayNameKey": "Activity_DecryptText_Property_KeyFormat_Name", + "TooltipKey": "Activity_DecryptText_Property_KeyFormat_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "KdfIterations", + "DisplayNameKey": "Activity_DecryptText_Property_KdfIterations_Name", + "TooltipKey": "Activity_DecryptText_Property_KdfIterations_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, { "Name": "ContinueOnError", "DisplayNameKey": "Activity_DecryptText_Property_ContinueOnError_Name", @@ -462,6 +534,54 @@ "DisplayNameKey": "Category_Options_Name" } }, + { + "Name": "Format", + "DisplayNameKey": "Activity_EncryptFile_Property_Format_Name", + "TooltipKey": "Activity_EncryptFile_Property_Format_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": true, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "KeyFormat", + "DisplayNameKey": "Activity_EncryptFile_Property_KeyFormat_Name", + "TooltipKey": "Activity_EncryptFile_Property_KeyFormat_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "Iv", + "DisplayNameKey": "Activity_EncryptFile_Property_Iv_Name", + "TooltipKey": "Activity_EncryptFile_Property_Iv_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "KdfIterations", + "DisplayNameKey": "Activity_EncryptFile_Property_KdfIterations_Name", + "TooltipKey": "Activity_EncryptFile_Property_KdfIterations_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, { "Name": "Overwrite", "DisplayNameKey": "Activity_EncryptFile_Property_Overwrite_Name", @@ -645,6 +765,54 @@ "DisplayNameKey": "Category_Options_Name" } }, + { + "Name": "Format", + "DisplayNameKey": "Activity_EncryptText_Property_Format_Name", + "TooltipKey": "Activity_EncryptText_Property_Format_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": true, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "KeyFormat", + "DisplayNameKey": "Activity_EncryptText_Property_KeyFormat_Name", + "TooltipKey": "Activity_EncryptText_Property_KeyFormat_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "Iv", + "DisplayNameKey": "Activity_EncryptText_Property_Iv_Name", + "TooltipKey": "Activity_EncryptText_Property_Iv_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, + { + "Name": "KdfIterations", + "DisplayNameKey": "Activity_EncryptText_Property_KdfIterations_Name", + "TooltipKey": "Activity_EncryptText_Property_KdfIterations_Description", + "IsRequired": false, + "IsPrincipal": false, + "IsVisible": false, + "Category": { + "Name": "Input", + "DisplayNameKey": "Input" + } + }, { "Name": "ContinueOnError", "DisplayNameKey": "Activity_EncryptText_Property_ContinueOnError_Name", @@ -1189,18 +1357,18 @@ ] }, { - "FullName": "UiPath.Cryptography.Activities.PgpClearsignFile", - "ShortName": "PgpClearsignFile", - "DisplayNameKey": "Activity_PgpClearsignFile_Name", + "FullName": "UiPath.Cryptography.Activities.PgpClearSignFile", + "ShortName": "PgpClearSignFile", + "DisplayNameKey": "Activity_PgpClearSignFile_Name", "DisplayNameAliasKeys": ["ActivitySynonymCryptography"], - "DescriptionKey": "Activity_PgpClearsignFile_Description", + "DescriptionKey": "Activity_PgpClearSignFile_Description", "IconKey": "PGP_clear_sign_file.svg", - "ViewModelType": "UiPath.Cryptography.Activities.NetCore.ViewModels.PgpClearsignFileViewModel", + "ViewModelType": "UiPath.Cryptography.Activities.NetCore.ViewModels.PgpClearSignFileViewModel", "Properties": [ { "Name": "InputFilePath", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_InputFilePath_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_InputFilePath_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_InputFilePath_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_InputFilePath_Description", "IsRequired": true, "IsPrincipal": true, "IsVisible": true, @@ -1211,8 +1379,8 @@ }, { "Name": "InputFile", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_InputFile_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_InputFile_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_InputFile_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_InputFile_Description", "IsPrincipal": true, "IsVisible": false, "Category": { @@ -1222,8 +1390,8 @@ }, { "Name": "PrivateKeyFilePath", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_PrivateKeyFilePath_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_PrivateKeyFilePath_Description", "IsRequired": true, "IsPrincipal": true, "IsVisible": true, @@ -1234,8 +1402,8 @@ }, { "Name": "PrivateKeyFile", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_PrivateKeyFile_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_PrivateKeyFile_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_PrivateKeyFile_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_PrivateKeyFile_Description", "IsPrincipal": true, "IsVisible": false, "Category": { @@ -1245,8 +1413,8 @@ }, { "Name": "Passphrase", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_Passphrase_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_Passphrase_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_Passphrase_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_Passphrase_Description", "IsRequired": true, "IsPrincipal": true, "IsVisible": true, @@ -1257,8 +1425,8 @@ }, { "Name": "PassphraseSecureString", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_PassphraseSecureString_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_PassphraseSecureString_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_PassphraseSecureString_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_PassphraseSecureString_Description", "IsRequired": true, "IsPrincipal": true, "IsVisible": true, @@ -1269,8 +1437,8 @@ }, { "Name": "OutputFilePath", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_OutputFilePath_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_OutputFilePath_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_OutputFilePath_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_OutputFilePath_Description", "IsRequired": false, "IsPrincipal": false, "IsVisible": true, @@ -1281,8 +1449,8 @@ }, { "Name": "Overwrite", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_Overwrite_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_Overwrite_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_Overwrite_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_Overwrite_Description", "IsRequired": false, "IsPrincipal": false, "IsVisible": true, @@ -1293,8 +1461,8 @@ }, { "Name": "ContinueOnError", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_ContinueOnError_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_ContinueOnError_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_ContinueOnError_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_ContinueOnError_Description", "IsRequired": false, "IsPrincipal": false, "IsVisible": true, @@ -1305,8 +1473,8 @@ }, { "Name": "ClearSignedFile", - "DisplayNameKey": "Activity_PgpClearsignFile_Property_ClearSignedFile_Name", - "TooltipKey": "Activity_PgpClearsignFile_Property_ClearSignedFile_Description", + "DisplayNameKey": "Activity_PgpClearSignFile_Property_ClearSignedFile_Name", + "TooltipKey": "Activity_PgpClearSignFile_Property_ClearSignedFile_Description", "IsRequired": false, "IsVisible": true, "Category": { diff --git a/Activities/Cryptography/UiPath.Cryptography/CryptographyHelper.cs b/Activities/Cryptography/UiPath.Cryptography/CryptographyHelper.cs index d157b1f41..47810c88a 100644 --- a/Activities/Cryptography/UiPath.Cryptography/CryptographyHelper.cs +++ b/Activities/Cryptography/UiPath.Cryptography/CryptographyHelper.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.ComponentModel; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; @@ -37,7 +36,17 @@ public static class CryptographyHelper { private static readonly RandomNumberGenerator _rng = RandomNumberGenerator.Create(); private const int PBKDF2_SaltSizeBytes = 8; // Value recommended in literature (64 bit key). - private const int PBKDF2_Iterations = 10000; // Value recommended in literature. + private const int PBKDF2_Iterations = 10000; // Frozen baseline for SymmetricWireFormat.Classic. + + // OWASP-recommended PBKDF2 iteration counts as of 2026. Used as defaults for the + // Owasp2026 and OpenSslEnc formats. Snapshot semantics: when OWASP revises these, + // we add a new SymmetricWireFormat entry (e.g. Owasp2030) rather than mutating these + // constants, so existing workflows keep producing byte-stable output. + private const int OwaspIterations_Sha1 = 1_300_000; + private const int OwaspIterations_Sha256 = 600_000; + + // "Salted__" magic prefix used by openssl enc. + private static readonly byte[] OpenSslMagic = new byte[] { 0x53, 0x61, 0x6C, 0x74, 0x65, 0x64, 0x5F, 0x5F }; public static byte[] HashDataWithKey(KeyedHashAlgorithms keyedHashAlgorithm, byte[] inputBytes, byte[] keyBytes) { @@ -58,120 +67,111 @@ public static byte[] HashDataWithKey(KeyedHashAlgorithms keyedHashAlgorithm, byt return result; } + // Public entry — Classic format (frozen at PBKDF2_Iterations = 10000, PBKDF2-HMAC-SHA1). + // Wire layout: salt(8) || IV || ciphertext, or salt(8) || IV(12) || ciphertext || tag(16) for AEAD. public static byte[] EncryptData(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] key) + => EncryptDataCore(algorithm, inputBytes, key, PBKDF2_Iterations); + + public static byte[] DecryptData(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] key) + => DecryptDataCore(algorithm, inputBytes, key, PBKDF2_Iterations); + + // Public entry — UiPath layout with caller-supplied PBKDF2-HMAC-SHA1 iteration count. + // Used by SymmetricWireFormat.Owasp2026 (default 1,300,000) and any future year-versioned + // snapshots that share the Classic wire layout. Output is byte-identical to Classic when iterations match. + public static byte[] EncryptDataWithIterations(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] passwordBytes, int iterations) + => EncryptDataCore(algorithm, inputBytes, passwordBytes, iterations); + + public static byte[] DecryptDataWithIterations(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] passwordBytes, int iterations) + => DecryptDataCore(algorithm, inputBytes, passwordBytes, iterations); + + private static byte[] EncryptDataCore(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] passwordBytes, int iterations) { if (algorithm == EncryptionAlgorithm.PGP) throw new ArgumentException("Use PGP-specific methods for PGP encryption.", nameof(algorithm)); - byte[] result; - if (algorithm == EncryptionAlgorithm.AESGCM) + return EncryptAesGcm(inputBytes, passwordBytes, iterations); + if (algorithm == EncryptionAlgorithm.ChaCha20Poly1305) + return EncryptChaCha20Poly1305(inputBytes, passwordBytes, iterations); + + byte[] result; + using (SymmetricAlgorithm symmetricAlgorithm = GetSymmetricAlgorithmProvider(algorithm)) { - return EncryptAesGcm(inputBytes, key); - } - else if (algorithm == EncryptionAlgorithm.ChaCha20Poly1305) - { - return EncryptChaCha20Poly1305(inputBytes, key); - } - else - { - using (SymmetricAlgorithm symmetricAlgorithm = GetSymmetricAlgorithmProvider(algorithm)) - { - byte[] encrypted; - byte[] salt = new byte[PBKDF2_SaltSizeBytes]; - int maxKeySize = GetLegalKeySizes(symmetricAlgorithm).Max(); + byte[] encrypted; + byte[] salt = new byte[PBKDF2_SaltSizeBytes]; + int maxKeySize = GetLegalKeySizes(symmetricAlgorithm).Max(); - _rng.GetBytes(salt); - using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(key, salt, PBKDF2_Iterations)) - { - symmetricAlgorithm.Key = pbkdf2.GetBytes(maxKeySize); - } + _rng.GetBytes(salt); + using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(passwordBytes, salt, iterations)) + { + symmetricAlgorithm.Key = pbkdf2.GetBytes(maxKeySize); + } - using (ICryptoTransform cryptoTransform = symmetricAlgorithm.CreateEncryptor()) + using (ICryptoTransform cryptoTransform = symmetricAlgorithm.CreateEncryptor()) + using (MemoryStream inputStream = new MemoryStream(inputBytes), transformedStream = new MemoryStream()) + { + using (CryptoStream cryptoStream = new CryptoStream(inputStream, cryptoTransform, CryptoStreamMode.Read)) { - using (MemoryStream inputStream = new MemoryStream(inputBytes), transformedStream = new MemoryStream()) - { - using (CryptoStream cryptoStream = new CryptoStream(inputStream, cryptoTransform, CryptoStreamMode.Read)) - { - cryptoStream.CopyTo(transformedStream); - } - - encrypted = transformedStream.ToArray(); - } + cryptoStream.CopyTo(transformedStream); } - - result = new byte[salt.Length + symmetricAlgorithm.IV.Length + encrypted.Length]; - Buffer.BlockCopy(salt, 0, result, 0, salt.Length); - Buffer.BlockCopy(symmetricAlgorithm.IV, 0, result, salt.Length, symmetricAlgorithm.IV.Length); - Buffer.BlockCopy(encrypted, 0, result, salt.Length + symmetricAlgorithm.IV.Length, encrypted.Length); + encrypted = transformedStream.ToArray(); } - return result; + result = new byte[salt.Length + symmetricAlgorithm.IV.Length + encrypted.Length]; + Buffer.BlockCopy(salt, 0, result, 0, salt.Length); + Buffer.BlockCopy(symmetricAlgorithm.IV, 0, result, salt.Length, symmetricAlgorithm.IV.Length); + Buffer.BlockCopy(encrypted, 0, result, salt.Length + symmetricAlgorithm.IV.Length, encrypted.Length); } + return result; } - public static byte[] DecryptData(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] key) + private static byte[] DecryptDataCore(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] passwordBytes, int iterations) { if (algorithm == EncryptionAlgorithm.PGP) throw new ArgumentException("Use PGP-specific methods for PGP decryption.", nameof(algorithm)); - byte[] decrypted; - if (algorithm == EncryptionAlgorithm.AESGCM) + return DecryptAesGcm(inputBytes, passwordBytes, iterations); + if (algorithm == EncryptionAlgorithm.ChaCha20Poly1305) + return DecryptChaCha20Poly1305(inputBytes, passwordBytes, iterations); + + byte[] decrypted; + using (SymmetricAlgorithm symmetricAlgorithm = GetSymmetricAlgorithmProvider(algorithm)) { - return DecryptAesGcm(inputBytes, key); - } - else if (algorithm == EncryptionAlgorithm.ChaCha20Poly1305) - { - return DecryptChaCha20Poly1305(inputBytes, key); - } - else - { - using (SymmetricAlgorithm symmetricAlgorithm = GetSymmetricAlgorithmProvider(algorithm)) - { - byte[] salt = new byte[PBKDF2_SaltSizeBytes]; - byte[] iv = new byte[symmetricAlgorithm.IV.Length]; + byte[] salt = new byte[PBKDF2_SaltSizeBytes]; + byte[] iv = new byte[symmetricAlgorithm.IV.Length]; - int minimumInputLength = salt.Length + iv.Length; - if (inputBytes.Length < minimumInputLength) - { - throw new CryptographicException(string.Format(Resources.SymmetricDecrypt_InputTooShort, minimumInputLength)); - } + int minimumInputLength = salt.Length + iv.Length; + if (inputBytes.Length < minimumInputLength) + throw new CryptographicException(string.Format(Resources.SymmetricDecrypt_InputTooShort, minimumInputLength)); - byte[] encryptedData = new byte[inputBytes.Length - salt.Length - iv.Length]; + byte[] encryptedData = new byte[inputBytes.Length - salt.Length - iv.Length]; + int maxKeySize = GetLegalKeySizes(symmetricAlgorithm).Max(); - int maxKeySize = GetLegalKeySizes(symmetricAlgorithm).Max(); + Buffer.BlockCopy(inputBytes, 0, salt, 0, salt.Length); + Buffer.BlockCopy(inputBytes, salt.Length, iv, 0, iv.Length); + Buffer.BlockCopy(inputBytes, salt.Length + iv.Length, encryptedData, 0, encryptedData.Length); - Buffer.BlockCopy(inputBytes, 0, salt, 0, salt.Length); - Buffer.BlockCopy(inputBytes, salt.Length, iv, 0, iv.Length); - Buffer.BlockCopy(inputBytes, salt.Length + iv.Length, encryptedData, 0, encryptedData.Length); + symmetricAlgorithm.IV = iv; + using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(passwordBytes, salt, iterations)) + { + symmetricAlgorithm.Key = pbkdf2.GetBytes(maxKeySize); + } - symmetricAlgorithm.IV = iv; - using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(key, salt, PBKDF2_Iterations)) + using (ICryptoTransform cryptoTransform = symmetricAlgorithm.CreateDecryptor()) + using (MemoryStream encryptedStream = new MemoryStream(encryptedData)) + using (CryptoStream cryptoStream = new CryptoStream(encryptedStream, cryptoTransform, CryptoStreamMode.Read)) + { + try { - symmetricAlgorithm.Key = pbkdf2.GetBytes(maxKeySize); + decrypted = cryptoStream.ReadToEnd(); } - - using (ICryptoTransform cryptoTransform = symmetricAlgorithm.CreateDecryptor()) + catch (CryptographicException ex) { - using (MemoryStream encryptedStream = new MemoryStream(encryptedData)) - { - using (CryptoStream cryptoStream = new CryptoStream(encryptedStream, cryptoTransform, CryptoStreamMode.Read)) - { - try - { - decrypted = cryptoStream.ReadToEnd(); - } - catch (CryptographicException ex) - { - throw new CryptographicException(Resources.SymmetricDecrypt_PaddingHint, ex); - } - } - } + throw new CryptographicException(Resources.SymmetricDecrypt_PaddingHint, ex); } } } - return decrypted; } @@ -294,12 +294,12 @@ private static int[] GetLegalKeySizes(SymmetricAlgorithm algorithm) private delegate void AeadEncryptCore(byte[] key, byte[] iv, byte[] plain, byte[] cipher, byte[] tag); private delegate void AeadDecryptCore(byte[] key, byte[] iv, byte[] cipher, byte[] tag, byte[] plain); - private static byte[] EncryptAead(byte[] inputBytes, byte[] key, AeadEncryptCore encryptCore) + private static byte[] EncryptAead(byte[] inputBytes, byte[] key, int iterations, AeadEncryptCore encryptCore) { InitializeAeadEncryption(out byte[] salt, out byte[] tag, out byte[] algorithmIV); byte[] encrypted = new byte[inputBytes.Length]; - using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(key, salt, PBKDF2_Iterations)) + using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(key, salt, iterations)) { var derivedKey = pbkdf2.GetBytes(AeadKeySizeBytes); encryptCore(derivedKey, algorithmIV, inputBytes, encrypted, tag); @@ -308,7 +308,7 @@ private static byte[] EncryptAead(byte[] inputBytes, byte[] key, AeadEncryptCore return CreateAeadEncryptionResult(encrypted, salt, tag, algorithmIV); } - private static byte[] DecryptAead(byte[] inputBytes, byte[] key, AeadDecryptCore decryptCore) + private static byte[] DecryptAead(byte[] inputBytes, byte[] key, int iterations, AeadDecryptCore decryptCore) { const int aeadMinimumInputLength = PBKDF2_SaltSizeBytes + AeadIvSizeBytes + AeadTagSizeBytes; if (inputBytes == null || inputBytes.Length < aeadMinimumInputLength) @@ -317,7 +317,7 @@ private static byte[] DecryptAead(byte[] inputBytes, byte[] key, AeadDecryptCore InitializeDecryptAead(inputBytes, out byte[] salt, out byte[] iv, out byte[] tag, out byte[] encryptedData); byte[] decrypted = new byte[encryptedData.Length]; - using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(key, salt, PBKDF2_Iterations)) + using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(key, salt, iterations)) { var derivedKey = pbkdf2.GetBytes(AeadKeySizeBytes); decryptCore(derivedKey, iv, encryptedData, tag, decrypted); @@ -326,22 +326,22 @@ private static byte[] DecryptAead(byte[] inputBytes, byte[] key, AeadDecryptCore return decrypted; } - private static byte[] EncryptAesGcm(byte[] inputBytes, byte[] key) => - EncryptAead(inputBytes, key, (k, iv, plain, cipher, tag) => + private static byte[] EncryptAesGcm(byte[] inputBytes, byte[] key, int iterations) => + EncryptAead(inputBytes, key, iterations, (k, iv, plain, cipher, tag) => { using var aes = new AesGcm(k); aes.Encrypt(iv, plain, cipher, tag); }); - private static byte[] DecryptAesGcm(byte[] inputBytes, byte[] key) => - DecryptAead(inputBytes, key, (k, iv, cipher, tag, plain) => + private static byte[] DecryptAesGcm(byte[] inputBytes, byte[] key, int iterations) => + DecryptAead(inputBytes, key, iterations, (k, iv, cipher, tag, plain) => { using var aes = new AesGcm(k); aes.Decrypt(iv, cipher, tag, plain); }); - private static byte[] EncryptChaCha20Poly1305(byte[] inputBytes, byte[] key) => - EncryptAead(inputBytes, key, (k, iv, plain, cipher, tag) => + private static byte[] EncryptChaCha20Poly1305(byte[] inputBytes, byte[] key, int iterations) => + EncryptAead(inputBytes, key, iterations, (k, iv, plain, cipher, tag) => { if (!ChaCha20Poly1305.IsSupported) throw new PlatformNotSupportedException(Resources.ChaCha20Poly1305NotSupported); @@ -349,8 +349,8 @@ private static byte[] EncryptChaCha20Poly1305(byte[] inputBytes, byte[] key) => chacha.Encrypt(iv, plain, cipher, tag); }); - private static byte[] DecryptChaCha20Poly1305(byte[] inputBytes, byte[] key) => - DecryptAead(inputBytes, key, (k, iv, cipher, tag, plain) => + private static byte[] DecryptChaCha20Poly1305(byte[] inputBytes, byte[] key, int iterations) => + DecryptAead(inputBytes, key, iterations, (k, iv, cipher, tag, plain) => { if (!ChaCha20Poly1305.IsSupported) throw new PlatformNotSupportedException(Resources.ChaCha20Poly1305NotSupported); @@ -389,6 +389,333 @@ private static void InitializeDecryptAead(byte[] inputBytes, out byte[] salt, ou Buffer.BlockCopy(inputBytes, salt.Length + iv.Length + encryptedData.Length, tag, 0, tag.Length); } + #region Third-party-compatible formats (Raw, OpenSslEnc) + + /// + /// Returns the OWASP-recommended PBKDF2 iteration count for the given format. + /// Throws for formats that do not run a KDF (Classic uses a fixed 10 000; Raw skips the KDF entirely). + /// + public static int GetRecommendedIterations(SymmetricWireFormat format) => format switch + { + SymmetricWireFormat.Owasp2026 => OwaspIterations_Sha1, + SymmetricWireFormat.OpenSslEnc => OwaspIterations_Sha256, + _ => throw new ArgumentException( + $"GetRecommendedIterations is undefined for {format}: Classic is frozen at 10 000 iterations and Raw skips the KDF.", + nameof(format)) + }; + + /// + /// Legal key sizes (in bytes) for the algorithm when supplied as a raw key. + /// AEAD algorithms accept only 32-byte (256-bit) keys; non-AEAD algorithms forward to the underlying SymmetricAlgorithm. + /// + public static int[] GetRawKeySizes(EncryptionAlgorithm algorithm) + { + if (algorithm == EncryptionAlgorithm.AESGCM || algorithm == EncryptionAlgorithm.ChaCha20Poly1305) + return new[] { AeadKeySizeBytes }; + using var symmetricAlgorithm = GetSymmetricAlgorithmProvider(algorithm); + return GetLegalKeySizes(symmetricAlgorithm); + } + + /// + /// IV size (in bytes) for the algorithm. AEAD algorithms always use 12-byte IVs; + /// non-AEAD algorithms use the SymmetricAlgorithm's natural block size. + /// + public static int GetIvSize(EncryptionAlgorithm algorithm) + { + if (algorithm == EncryptionAlgorithm.AESGCM || algorithm == EncryptionAlgorithm.ChaCha20Poly1305) + return AeadIvSizeBytes; + using var symmetricAlgorithm = GetSymmetricAlgorithmProvider(algorithm); + return symmetricAlgorithm.IV.Length; + } + + /// + /// Raw-key encrypt: caller supplies the literal cipher key bytes and (optionally) the IV. + /// Output layout: IV || ciphertext for non-AEAD; IV(12) || ciphertext || tag(16) for AEAD. + /// If is null, a fresh random IV is generated. + /// + public static byte[] EncryptDataRaw(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] keyBytes, byte[] iv) + { + if (algorithm == EncryptionAlgorithm.PGP) + throw new ArgumentException("Use PGP-specific methods for PGP encryption.", nameof(algorithm)); + + if (algorithm == EncryptionAlgorithm.AESGCM || algorithm == EncryptionAlgorithm.ChaCha20Poly1305) + { + byte[] effectiveIv = iv; + if (effectiveIv == null) + { + effectiveIv = new byte[AeadIvSizeBytes]; + _rng.GetBytes(effectiveIv); + } + if (effectiveIv.Length != AeadIvSizeBytes) + throw new ArgumentException($"AEAD IV must be exactly {AeadIvSizeBytes} bytes; got {effectiveIv.Length}.", nameof(iv)); + + byte[] cipherText = new byte[inputBytes.Length]; + byte[] tag = new byte[AeadTagSizeBytes]; + + if (algorithm == EncryptionAlgorithm.AESGCM) + { + using var aes = new AesGcm(keyBytes); + aes.Encrypt(effectiveIv, inputBytes, cipherText, tag); + } + else + { + if (!ChaCha20Poly1305.IsSupported) + throw new PlatformNotSupportedException(Resources.ChaCha20Poly1305NotSupported); + using var chacha = new ChaCha20Poly1305(keyBytes); + chacha.Encrypt(effectiveIv, inputBytes, cipherText, tag); + } + + byte[] result = new byte[effectiveIv.Length + cipherText.Length + tag.Length]; + Buffer.BlockCopy(effectiveIv, 0, result, 0, effectiveIv.Length); + Buffer.BlockCopy(cipherText, 0, result, effectiveIv.Length, cipherText.Length); + Buffer.BlockCopy(tag, 0, result, effectiveIv.Length + cipherText.Length, tag.Length); + return result; + } + + using var symmetric = GetSymmetricAlgorithmProvider(algorithm); + symmetric.Key = keyBytes; + if (iv != null) + { + if (iv.Length != symmetric.IV.Length) + throw new ArgumentException($"IV for {algorithm} must be {symmetric.IV.Length} bytes; got {iv.Length}.", nameof(iv)); + symmetric.IV = iv; + } + + byte[] encrypted; + using (ICryptoTransform transform = symmetric.CreateEncryptor()) + using (MemoryStream inputStream = new MemoryStream(inputBytes), outStream = new MemoryStream()) + { + using (CryptoStream cryptoStream = new CryptoStream(inputStream, transform, CryptoStreamMode.Read)) + cryptoStream.CopyTo(outStream); + encrypted = outStream.ToArray(); + } + + byte[] rawResult = new byte[symmetric.IV.Length + encrypted.Length]; + Buffer.BlockCopy(symmetric.IV, 0, rawResult, 0, symmetric.IV.Length); + Buffer.BlockCopy(encrypted, 0, rawResult, symmetric.IV.Length, encrypted.Length); + return rawResult; + } + + /// + /// Raw-key decrypt: parses IV || ciphertext [|| tag], using the caller-supplied raw key. + /// + public static byte[] DecryptDataRaw(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] keyBytes) + { + if (algorithm == EncryptionAlgorithm.PGP) + throw new ArgumentException("Use PGP-specific methods for PGP decryption.", nameof(algorithm)); + + if (algorithm == EncryptionAlgorithm.AESGCM || algorithm == EncryptionAlgorithm.ChaCha20Poly1305) + { + int minLen = AeadIvSizeBytes + AeadTagSizeBytes; + if (inputBytes == null || inputBytes.Length < minLen) + throw new CryptographicException(string.Format(Resources.SymmetricDecrypt_InputTooShort, minLen)); + + byte[] iv = new byte[AeadIvSizeBytes]; + byte[] tag = new byte[AeadTagSizeBytes]; + byte[] cipher = new byte[inputBytes.Length - iv.Length - tag.Length]; + Buffer.BlockCopy(inputBytes, 0, iv, 0, iv.Length); + Buffer.BlockCopy(inputBytes, iv.Length, cipher, 0, cipher.Length); + Buffer.BlockCopy(inputBytes, iv.Length + cipher.Length, tag, 0, tag.Length); + + byte[] plain = new byte[cipher.Length]; + if (algorithm == EncryptionAlgorithm.AESGCM) + { + using var aes = new AesGcm(keyBytes); + aes.Decrypt(iv, cipher, tag, plain); + } + else + { + if (!ChaCha20Poly1305.IsSupported) + throw new PlatformNotSupportedException(Resources.ChaCha20Poly1305NotSupported); + using var chacha = new ChaCha20Poly1305(keyBytes); + chacha.Decrypt(iv, cipher, tag, plain); + } + return plain; + } + + using var symmetric = GetSymmetricAlgorithmProvider(algorithm); + int ivLen = symmetric.IV.Length; + if (inputBytes == null || inputBytes.Length < ivLen) + throw new CryptographicException(string.Format(Resources.SymmetricDecrypt_InputTooShort, ivLen)); + + byte[] symIv = new byte[ivLen]; + byte[] symCipher = new byte[inputBytes.Length - ivLen]; + Buffer.BlockCopy(inputBytes, 0, symIv, 0, ivLen); + Buffer.BlockCopy(inputBytes, ivLen, symCipher, 0, symCipher.Length); + + symmetric.IV = symIv; + symmetric.Key = keyBytes; + + byte[] decrypted; + using (ICryptoTransform transform = symmetric.CreateDecryptor()) + using (MemoryStream encStream = new MemoryStream(symCipher)) + using (CryptoStream cryptoStream = new CryptoStream(encStream, transform, CryptoStreamMode.Read)) + { + try + { + decrypted = cryptoStream.ReadToEnd(); + } + catch (CryptographicException ex) + { + throw new CryptographicException(Resources.SymmetricDecrypt_PaddingHint, ex); + } + } + return decrypted; + } + + /// + /// OpenSSL enc-compatible encrypt: layout Salted__(8) || salt(8) || ciphertext [|| tag], + /// key (and IV, for non-AEAD and AEAD alike) derived via PBKDF2-HMAC-SHA256. + /// AEAD layout is a UiPath extension — see docs/symmetric-wire-format.md. + /// + public static byte[] EncryptDataOpenSslEnc(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] passwordBytes, int iterations) + { + if (algorithm == EncryptionAlgorithm.PGP) + throw new ArgumentException("Use PGP-specific methods for PGP encryption.", nameof(algorithm)); + + byte[] salt = new byte[PBKDF2_SaltSizeBytes]; + _rng.GetBytes(salt); + + if (algorithm == EncryptionAlgorithm.AESGCM || algorithm == EncryptionAlgorithm.ChaCha20Poly1305) + { + var (aeadKey, aeadIv) = DeriveOpenSslKeyAndIv(passwordBytes, salt, iterations, AeadKeySizeBytes, AeadIvSizeBytes); + byte[] cipher = new byte[inputBytes.Length]; + byte[] tag = new byte[AeadTagSizeBytes]; + + if (algorithm == EncryptionAlgorithm.AESGCM) + { + using var aes = new AesGcm(aeadKey); + aes.Encrypt(aeadIv, inputBytes, cipher, tag); + } + else + { + if (!ChaCha20Poly1305.IsSupported) + throw new PlatformNotSupportedException(Resources.ChaCha20Poly1305NotSupported); + using var chacha = new ChaCha20Poly1305(aeadKey); + chacha.Encrypt(aeadIv, inputBytes, cipher, tag); + } + + byte[] aeadResult = new byte[OpenSslMagic.Length + salt.Length + cipher.Length + tag.Length]; + Buffer.BlockCopy(OpenSslMagic, 0, aeadResult, 0, OpenSslMagic.Length); + Buffer.BlockCopy(salt, 0, aeadResult, OpenSslMagic.Length, salt.Length); + Buffer.BlockCopy(cipher, 0, aeadResult, OpenSslMagic.Length + salt.Length, cipher.Length); + Buffer.BlockCopy(tag, 0, aeadResult, OpenSslMagic.Length + salt.Length + cipher.Length, tag.Length); + return aeadResult; + } + + using var symmetric = GetSymmetricAlgorithmProvider(algorithm); + int symKeySize = GetLegalKeySizes(symmetric).Max(); + int symIvSize = symmetric.IV.Length; + var (symKey, symIv) = DeriveOpenSslKeyAndIv(passwordBytes, salt, iterations, symKeySize, symIvSize); + symmetric.Key = symKey; + symmetric.IV = symIv; + + byte[] encrypted; + using (ICryptoTransform transform = symmetric.CreateEncryptor()) + using (MemoryStream inputStream = new MemoryStream(inputBytes), outStream = new MemoryStream()) + { + using (CryptoStream cryptoStream = new CryptoStream(inputStream, transform, CryptoStreamMode.Read)) + cryptoStream.CopyTo(outStream); + encrypted = outStream.ToArray(); + } + + byte[] result = new byte[OpenSslMagic.Length + salt.Length + encrypted.Length]; + Buffer.BlockCopy(OpenSslMagic, 0, result, 0, OpenSslMagic.Length); + Buffer.BlockCopy(salt, 0, result, OpenSslMagic.Length, salt.Length); + Buffer.BlockCopy(encrypted, 0, result, OpenSslMagic.Length + salt.Length, encrypted.Length); + return result; + } + + /// + /// OpenSSL enc-compatible decrypt: parses Salted__(8) || salt(8) || ciphertext [|| tag]. + /// + public static byte[] DecryptDataOpenSslEnc(EncryptionAlgorithm algorithm, byte[] inputBytes, byte[] passwordBytes, int iterations) + { + if (algorithm == EncryptionAlgorithm.PGP) + throw new ArgumentException("Use PGP-specific methods for PGP decryption.", nameof(algorithm)); + + int prefixLen = OpenSslMagic.Length + PBKDF2_SaltSizeBytes; + if (inputBytes == null || inputBytes.Length < prefixLen) + throw new CryptographicException(string.Format(Resources.SymmetricDecrypt_InputTooShort, prefixLen)); + + for (int i = 0; i < OpenSslMagic.Length; i++) + { + if (inputBytes[i] != OpenSslMagic[i]) + throw new CryptographicException(Resources.OpenSslEnc_MissingMagic); + } + + byte[] salt = new byte[PBKDF2_SaltSizeBytes]; + Buffer.BlockCopy(inputBytes, OpenSslMagic.Length, salt, 0, PBKDF2_SaltSizeBytes); + + if (algorithm == EncryptionAlgorithm.AESGCM || algorithm == EncryptionAlgorithm.ChaCha20Poly1305) + { + int minAead = prefixLen + AeadTagSizeBytes; + if (inputBytes.Length < minAead) + throw new CryptographicException(string.Format(Resources.SymmetricDecrypt_InputTooShort, minAead)); + + var (aeadKey, aeadIv) = DeriveOpenSslKeyAndIv(passwordBytes, salt, iterations, AeadKeySizeBytes, AeadIvSizeBytes); + int cipherLen = inputBytes.Length - prefixLen - AeadTagSizeBytes; + byte[] cipher = new byte[cipherLen]; + byte[] tag = new byte[AeadTagSizeBytes]; + byte[] plain = new byte[cipherLen]; + Buffer.BlockCopy(inputBytes, prefixLen, cipher, 0, cipherLen); + Buffer.BlockCopy(inputBytes, prefixLen + cipherLen, tag, 0, AeadTagSizeBytes); + + if (algorithm == EncryptionAlgorithm.AESGCM) + { + using var aes = new AesGcm(aeadKey); + aes.Decrypt(aeadIv, cipher, tag, plain); + } + else + { + if (!ChaCha20Poly1305.IsSupported) + throw new PlatformNotSupportedException(Resources.ChaCha20Poly1305NotSupported); + using var chacha = new ChaCha20Poly1305(aeadKey); + chacha.Decrypt(aeadIv, cipher, tag, plain); + } + return plain; + } + + using var symmetric = GetSymmetricAlgorithmProvider(algorithm); + int symKeySize = GetLegalKeySizes(symmetric).Max(); + int symIvSize = symmetric.IV.Length; + var (symKey, symIv) = DeriveOpenSslKeyAndIv(passwordBytes, salt, iterations, symKeySize, symIvSize); + symmetric.Key = symKey; + symmetric.IV = symIv; + + byte[] encrypted = new byte[inputBytes.Length - prefixLen]; + Buffer.BlockCopy(inputBytes, prefixLen, encrypted, 0, encrypted.Length); + + byte[] decrypted; + using (ICryptoTransform transform = symmetric.CreateDecryptor()) + using (MemoryStream encStream = new MemoryStream(encrypted)) + using (CryptoStream cryptoStream = new CryptoStream(encStream, transform, CryptoStreamMode.Read)) + { + try + { + decrypted = cryptoStream.ReadToEnd(); + } + catch (CryptographicException ex) + { + throw new CryptographicException(Resources.SymmetricDecrypt_PaddingHint, ex); + } + } + return decrypted; + } + + private static (byte[] key, byte[] iv) DeriveOpenSslKeyAndIv(byte[] password, byte[] salt, int iterations, int keySize, int ivSize) + { + using var pbkdf2 = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256); + byte[] derived = pbkdf2.GetBytes(keySize + ivSize); + byte[] key = new byte[keySize]; + byte[] iv = new byte[ivSize]; + Buffer.BlockCopy(derived, 0, key, 0, keySize); + Buffer.BlockCopy(derived, keySize, iv, 0, ivSize); + return (key, iv); + } + + #endregion + #region PGP Methods private static Exception TranslatePgpException(Exception ex) @@ -702,6 +1029,62 @@ public static byte[] KeyEncoding(Encoding encoding, string key, SecureString key return key != null ? encoding.GetBytes(key) : encoding.GetBytes(new NetworkCredential("", keySecureString).Password); } + /// + /// Parse a key (or IV) string per the chosen . Encoded + /// reuses the existing password-via-Encoding path; Hex and Base64 are required + /// when supplying a literal raw key (Format = Raw). + /// + public static byte[] ParseKeyBytes(string keyString, SecureString keySecureString, KeyBytesFormat format, Encoding encoding) + { + switch (format) + { + case KeyBytesFormat.Encoded: + if (encoding == null) + throw new ArgumentNullException(nameof(encoding), "Encoding is required when KeyFormat = Encoded."); + return KeyEncoding(encoding, keyString, keySecureString); + + case KeyBytesFormat.Hex: + { + string raw = keyString ?? (keySecureString != null ? new NetworkCredential(string.Empty, keySecureString).Password : null); + if (string.IsNullOrEmpty(raw)) + throw new ArgumentException("Hex key/IV string is empty."); + return FromHexString(raw); + } + + case KeyBytesFormat.Base64: + { + string raw = keyString ?? (keySecureString != null ? new NetworkCredential(string.Empty, keySecureString).Password : null); + if (string.IsNullOrEmpty(raw)) + throw new ArgumentException("Base64 key/IV string is empty."); + return Convert.FromBase64String(raw); + } + + default: + throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown KeyBytesFormat."); + } + } + + private static byte[] FromHexString(string hex) + { + // Tolerate "0x" prefix and any embedded whitespace/colons typical of hex dumps. + var cleaned = new StringBuilder(hex.Length); + int start = (hex.Length >= 2 && hex[0] == '0' && (hex[1] == 'x' || hex[1] == 'X')) ? 2 : 0; + for (int i = start; i < hex.Length; i++) + { + char c = hex[i]; + if (c == ' ' || c == ':' || c == '-' || c == '\t' || c == '\r' || c == '\n') continue; + cleaned.Append(c); + } + if ((cleaned.Length & 1) != 0) + throw new ArgumentException("Hex string has an odd number of digits."); + byte[] bytes = new byte[cleaned.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { + bytes[i] = byte.Parse(cleaned.ToString(i * 2, 2), System.Globalization.NumberStyles.HexNumber); + } + return bytes; + } + /// /// Stream wrapper that delegates all operations to an inner stream but suppresses Close/Dispose, /// preventing BouncyCastle's decoder stream from closing a caller-owned stream. diff --git a/Activities/Cryptography/UiPath.Cryptography/KeyBytesFormat.cs b/Activities/Cryptography/UiPath.Cryptography/KeyBytesFormat.cs new file mode 100644 index 000000000..616d32201 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography/KeyBytesFormat.cs @@ -0,0 +1,43 @@ +using UiPath.Cryptography.Properties; + +namespace UiPath.Cryptography +{ + /// + /// How the Key (and, for , Iv) + /// strings are converted to bytes. Encoded matches the historical + /// UiPath behavior (password → bytes via Encoding); Hex and + /// Base64 are required when supplying a literal raw key. + /// + public enum KeyBytesFormat + { + /// + /// The Key string is a password — transcoded to bytes through the activity's + /// Encoding property (UTF-8 by default). The resulting bytes feed into PBKDF2, + /// which derives the actual cipher key. Default. Compatible with + /// , , + /// and ; rejected for + /// . + /// + [LocalizedDescription(nameof(Resources.KeyBytesFormat_Encoded))] + Encoded = 0, + + /// + /// The Key string is the literal cipher key encoded as hexadecimal — even + /// number of hex digits (0-9, a-f, A-F), no KDF, no Encoding involvement. The + /// decoded byte length must match a legal key size for the algorithm (e.g. 32 hex + /// chars / 16 bytes for AES-128, 64 hex chars / 32 bytes for AES-256). Required for + /// ; rejected for the other formats. + /// + [LocalizedDescription(nameof(Resources.KeyBytesFormat_Hex))] + Hex = 1, + + /// + /// The Key string is the literal cipher key encoded as Base64 (RFC 4648, + /// optionally padded with =), no KDF, no Encoding involvement. The + /// decoded byte length must match a legal key size for the algorithm. Required for + /// ; rejected for the other formats. + /// + [LocalizedDescription(nameof(Resources.KeyBytesFormat_Base64))] + Base64 = 2, + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography/Properties/UiPath.Cryptography.Designer.cs b/Activities/Cryptography/UiPath.Cryptography/Properties/UiPath.Cryptography.Designer.cs index c7cd7db95..8908f5531 100644 --- a/Activities/Cryptography/UiPath.Cryptography/Properties/UiPath.Cryptography.Designer.cs +++ b/Activities/Cryptography/UiPath.Cryptography/Properties/UiPath.Cryptography.Designer.cs @@ -1086,6 +1086,33 @@ internal static string Key { } } + /// + /// Looks up a localized string similar to Base64. + /// + internal static string KeyBytesFormat_Base64 { + get { + return ResourceManager.GetString("KeyBytesFormat_Base64", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Encoded (text password via Encoding). + /// + internal static string KeyBytesFormat_Encoded { + get { + return ResourceManager.GetString("KeyBytesFormat_Encoded", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Hex. + /// + internal static string KeyBytesFormat_Hex { + get { + return ResourceManager.GetString("KeyBytesFormat_Hex", resourceCulture); + } + } + /// /// Looks up a localized string similar to Korean. /// @@ -1221,6 +1248,15 @@ internal static string OEMUnitedStates { } } + /// + /// Looks up a localized string similar to The ciphertext does not start with the openssl 'Salted__' magic prefix. Confirm the producer used 'openssl enc -salt' and the output was not stripped or transcoded.. + /// + internal static string OpenSslEnc_MissingMagic { + get { + return ResourceManager.GetString("OpenSslEnc_MissingMagic", resourceCulture); + } + } + /// /// Looks up a localized string similar to PGP - Pretty Good Privacy (Non-FIPS). /// @@ -1276,7 +1312,7 @@ internal static string PgpVerificationRequiresPublicKey { } /// - /// Looks up a localized string similar to Clearsigned File (Text). + /// Looks up a localized string similar to ClearSigned File (Text). /// internal static string PgpVerifyMode_ClearSignature { get { @@ -1446,6 +1482,42 @@ internal static string SymmetricDecrypt_PaddingHint { } } + /// + /// Looks up a localized string similar to UiPath (Classic). + /// + internal static string SymmetricWireFormat_Classic { + get { + return ResourceManager.GetString("SymmetricWireFormat_Classic", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to OpenSSL enc (Salted__ + PBKDF2-SHA256). + /// + internal static string SymmetricWireFormat_OpenSslEnc { + get { + return ResourceManager.GetString("SymmetricWireFormat_OpenSslEnc", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to UiPath (OWASP 2026). + /// + internal static string SymmetricWireFormat_Owasp2026 { + get { + return ResourceManager.GetString("SymmetricWireFormat_Owasp2026", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Raw (caller-supplied key and IV). + /// + internal static string SymmetricWireFormat_Raw { + get { + return ResourceManager.GetString("SymmetricWireFormat_Raw", resourceCulture); + } + } + /// /// Looks up a localized string similar to T.61. /// @@ -1697,5 +1769,59 @@ internal static string WesternEuropean_Windows { return ResourceManager.GetString("WesternEuropean_Windows", resourceCulture); } } + + /// + /// Looks up a localized string similar to Key bytes format = Encoded is not allowed with wire format = Raw. Supply a literal raw key as Hex or Base64.. + /// + internal static string Validation_RawKeyFormat_EncodedNotAllowed { + get { + return ResourceManager.GetString("Validation_RawKeyFormat_EncodedNotAllowed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Raw key length is {0} bytes, which is not a legal key size for the chosen algorithm. Expected one of: {1} bytes.. + /// + internal static string Validation_RawKey_LengthMismatch { + get { + return ResourceManager.GetString("Validation_RawKey_LengthMismatch", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Key bytes format = Hex or Base64 is only valid with wire format = Raw. The Classic, Owasp2026, and OpenSslEnc formats use the Key string as a password (Encoded).. + /// + internal static string Validation_PasswordFormat_NonEncodedNotAllowed { + get { + return ResourceManager.GetString("Validation_PasswordFormat_NonEncodedNotAllowed", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to IV may only be set when wire format = Raw. The other formats embed the IV in the ciphertext stream.. + /// + internal static string Validation_Iv_OnlyForRaw { + get { + return ResourceManager.GetString("Validation_Iv_OnlyForRaw", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to KDF iterations may only be set for wire format = Owasp2026 or OpenSslEnc. Classic is frozen at 10,000 iterations and Raw does not run a KDF.. + /// + internal static string Validation_KdfIterations_NotForClassicOrRaw { + get { + return ResourceManager.GetString("Validation_KdfIterations_NotForClassicOrRaw", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to KDF iterations = {0} is below the {1}-iteration minimum (NIST SP 800-132 floor). OWASP currently recommends 1,300,000 for PBKDF2-SHA1 and 600,000 for PBKDF2-SHA256.. + /// + internal static string Validation_KdfIterations_BelowMinimum { + get { + return ResourceManager.GetString("Validation_KdfIterations_BelowMinimum", resourceCulture); + } + } } } diff --git a/Activities/Cryptography/UiPath.Cryptography/Properties/UiPath.Cryptography.resx b/Activities/Cryptography/UiPath.Cryptography/Properties/UiPath.Cryptography.resx index ea5890f2c..e3eb7aef4 100644 --- a/Activities/Cryptography/UiPath.Cryptography/Properties/UiPath.Cryptography.resx +++ b/Activities/Cryptography/UiPath.Cryptography/Properties/UiPath.Cryptography.resx @@ -1,17 +1,17 @@  - @@ -644,7 +644,7 @@ Signed File (Binary) - Clearsigned File (Text) + ClearSigned File (Text) Validate Public Key @@ -664,4 +664,48 @@ Signature verification was requested but a public key is required to verify. Provide a public key stream, or set verifySignature to false. + + UiPath (Classic) + + + UiPath (OWASP 2026) + + + Raw (caller-supplied key and IV) + + + OpenSSL enc (Salted__ + PBKDF2-SHA256) + + + Encoded (text password via Encoding) + + + Hex + + + Base64 + + + The ciphertext does not start with the openssl 'Salted__' magic prefix. Confirm the producer used 'openssl enc -salt' and the output was not stripped or transcoded. + + + + + Key bytes format = Encoded is not allowed with wire format = Raw. Supply a literal raw key as Hex or Base64. + + + Raw key length is {0} bytes, which is not a legal key size for the chosen algorithm. Expected one of: {1} bytes. + + + Key bytes format = Hex or Base64 is only valid with wire format = Raw. The Classic, Owasp2026, and OpenSslEnc formats use the Key string as a password (Encoded). + + + IV may only be set when wire format = Raw. The other formats embed the IV in the ciphertext stream. + + + KDF iterations may only be set for wire format = Owasp2026 or OpenSslEnc. Classic is frozen at 10,000 iterations and Raw does not run a KDF. + + + KDF iterations = {0} is below the {1}-iteration minimum (NIST SP 800-132 floor). OWASP currently recommends 1,300,000 for PBKDF2-SHA1 and 600,000 for PBKDF2-SHA256. + \ No newline at end of file diff --git a/Activities/Cryptography/UiPath.Cryptography/SymmetricInteropHelper.cs b/Activities/Cryptography/UiPath.Cryptography/SymmetricInteropHelper.cs new file mode 100644 index 000000000..0870a5519 --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography/SymmetricInteropHelper.cs @@ -0,0 +1,190 @@ +using System; +using System.Net; +using System.Security; +using System.Text; +using UiPath.Cryptography.Properties; + +#pragma warning disable CS0618 // obsolete encryption algorithms reachable via opt-in formats + +namespace UiPath.Cryptography +{ + /// + /// Validation + dispatch glue for the four modes. + /// Activities and the coded-workflow service both call into here after resolving their + /// own input values; the helper enforces the cross-property invariants + /// (see docs/symmetric-wire-format.md) and routes to . + /// + /// + /// This class is public only because it crosses assembly boundaries inside the + /// package (the activities and the coded-workflow service both consume it). Treat it as + /// internal: it may change, gain or lose methods, or be removed without notice. + /// + public static class SymmetricInteropHelper + { + public const int MinKdfIterations = 1000; + + public static void ValidateInteropSettings( + EncryptionAlgorithm algorithm, + SymmetricWireFormat format, + KeyBytesFormat keyFormat, + string ivString, + int kdfIterations, + int? rawKeyLengthBytes) + { + if (format == SymmetricWireFormat.Raw && keyFormat == KeyBytesFormat.Encoded) + throw new ArgumentException(Resources.Validation_RawKeyFormat_EncodedNotAllowed); + if (format != SymmetricWireFormat.Raw && keyFormat != KeyBytesFormat.Encoded) + throw new ArgumentException(Resources.Validation_PasswordFormat_NonEncodedNotAllowed); + + if (format != SymmetricWireFormat.Raw && !string.IsNullOrEmpty(ivString)) + throw new ArgumentException(Resources.Validation_Iv_OnlyForRaw); + + if (kdfIterations != 0 && (format == SymmetricWireFormat.Classic || format == SymmetricWireFormat.Raw)) + 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)) + throw new ArgumentException(string.Format(Resources.Validation_KdfIterations_BelowMinimum, kdfIterations, MinKdfIterations)); + + if (format == SymmetricWireFormat.Raw && rawKeyLengthBytes.HasValue) + { + int[] legal = CryptographyHelper.GetRawKeySizes(algorithm); + if (Array.IndexOf(legal, rawKeyLengthBytes.Value) < 0) + { + throw new ArgumentException(string.Format( + Resources.Validation_RawKey_LengthMismatch, + rawKeyLengthBytes.Value, + string.Join(", ", legal))); + } + } + } + + /// + /// Resolves a key/IV string (either plain or via ) into bytes + /// per the chosen . Returns null when both inputs are empty. + /// + public static byte[] ParseKeyOrIv(string value, SecureString secureValue, KeyBytesFormat format, Encoding encoding) + { + bool hasPlain = !string.IsNullOrEmpty(value); + bool hasSecure = secureValue != null && secureValue.Length > 0; + if (!hasPlain && !hasSecure) + return null; + + if (format == KeyBytesFormat.Encoded) + return CryptographyHelper.KeyEncoding(encoding, hasPlain ? value : null, hasSecure ? secureValue : null); + + string raw = hasPlain ? value : new NetworkCredential(string.Empty, secureValue).Password; + return CryptographyHelper.ParseKeyBytes(raw, null, format, null); + } + + /// + /// Zero a freshly-materialised key buffer in place. Call in a finally after the + /// dispatcher returns so the secret bytes (raw cipher key, or password material + /// materialised from a SecureString) do not survive on the managed heap until GC. + /// Safe to call with null or an empty array. + /// + public static void ClearKeyBytes(byte[] bytes) + { + if (bytes != null && bytes.Length > 0) + Array.Clear(bytes, 0, bytes.Length); + } + + public static byte[] DispatchEncrypt( + EncryptionAlgorithm algorithm, + SymmetricWireFormat format, + int kdfIterations, + byte[] keyOrPasswordBytes, + byte[] ivBytes, + byte[] inputBytes) + { + switch (format) + { + case SymmetricWireFormat.Classic: + return CryptographyHelper.EncryptData(algorithm, inputBytes, keyOrPasswordBytes); + case SymmetricWireFormat.Owasp2026: + { + int iter = kdfIterations > 0 ? kdfIterations : CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.Owasp2026); + return CryptographyHelper.EncryptDataWithIterations(algorithm, inputBytes, keyOrPasswordBytes, iter); + } + case SymmetricWireFormat.Raw: + return CryptographyHelper.EncryptDataRaw(algorithm, inputBytes, keyOrPasswordBytes, ivBytes); + case SymmetricWireFormat.OpenSslEnc: + { + int iter = kdfIterations > 0 ? kdfIterations : CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.OpenSslEnc); + return CryptographyHelper.EncryptDataOpenSslEnc(algorithm, inputBytes, keyOrPasswordBytes, iter); + } + default: + throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown SymmetricWireFormat"); + } + } + + // Owns the full validate → parse key/IV → re-validate(Raw) → dispatch → finally{ClearKeyBytes} + // lifecycle for symmetric encrypt/decrypt. The four XAML activities (EncryptText, DecryptText, + // EncryptFile, DecryptFile) all need this exact sequence; centralising it means the key-zeroing + // finally lives in one place and cannot be silently dropped by a future fix that touches only + // three of the four activities. Callers pass already-resolved settings + payload via the + // dispatch lambda (which is where per-activity error wrapping lives, e.g. Decrypt* wrapping + // CryptographicException in InvalidOperationException). + public static TOut RunSymmetricWithKeyLifecycle( + EncryptionAlgorithm algorithm, + SymmetricWireFormat format, + KeyBytesFormat keyFormat, + Encoding keyEncoding, + string keyString, + SecureString keySecureString, + string ivString, + int kdfIterations, + bool needsIv, + Func dispatch) + { + if (dispatch == null) throw new ArgumentNullException(nameof(dispatch)); + + ValidateInteropSettings(algorithm, format, keyFormat, ivString, kdfIterations, null); + + byte[] keyOrPasswordBytes = ParseKeyOrIv(keyString, keySecureString, keyFormat, keyEncoding); + byte[] ivBytes = needsIv + ? ParseKeyOrIv(ivString, null, keyFormat, keyEncoding) + : null; + + if (format == SymmetricWireFormat.Raw) + ValidateInteropSettings(algorithm, format, keyFormat, ivString, kdfIterations, keyOrPasswordBytes?.Length); + + try + { + return dispatch(keyOrPasswordBytes, ivBytes); + } + finally + { + ClearKeyBytes(keyOrPasswordBytes); + } + } + + public static byte[] DispatchDecrypt( + EncryptionAlgorithm algorithm, + SymmetricWireFormat format, + int kdfIterations, + byte[] keyOrPasswordBytes, + byte[] inputBytes) + { + switch (format) + { + case SymmetricWireFormat.Classic: + return CryptographyHelper.DecryptData(algorithm, inputBytes, keyOrPasswordBytes); + case SymmetricWireFormat.Owasp2026: + { + int iter = kdfIterations > 0 ? kdfIterations : CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.Owasp2026); + return CryptographyHelper.DecryptDataWithIterations(algorithm, inputBytes, keyOrPasswordBytes, iter); + } + case SymmetricWireFormat.Raw: + return CryptographyHelper.DecryptDataRaw(algorithm, inputBytes, keyOrPasswordBytes); + case SymmetricWireFormat.OpenSslEnc: + { + int iter = kdfIterations > 0 ? kdfIterations : CryptographyHelper.GetRecommendedIterations(SymmetricWireFormat.OpenSslEnc); + return CryptographyHelper.DecryptDataOpenSslEnc(algorithm, inputBytes, keyOrPasswordBytes, iter); + } + default: + throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown SymmetricWireFormat"); + } + } + } +} diff --git a/Activities/Cryptography/UiPath.Cryptography/SymmetricWireFormat.cs b/Activities/Cryptography/UiPath.Cryptography/SymmetricWireFormat.cs new file mode 100644 index 000000000..ba8d1872f --- /dev/null +++ b/Activities/Cryptography/UiPath.Cryptography/SymmetricWireFormat.cs @@ -0,0 +1,56 @@ +using UiPath.Cryptography.Properties; + +namespace UiPath.Cryptography +{ + /// + /// Selects the byte layout and key-derivation strategy used by the symmetric + /// encrypt/decrypt activities. See docs/symmetric-wire-format.md for the + /// full reference, including third-party interop notes. + /// + public enum SymmetricWireFormat + { + /// + /// UiPath's frozen, byte-stable layout: salt(8) ‖ IV ‖ ciphertext [‖ tag(16)], + /// PBKDF2-HMAC-SHA1 @ 10 000 iterations. Default. Required for back-compat with + /// ciphertext produced by every prior release of this package. Not directly + /// interoperable with openssl enc or other standard tools. + /// + [LocalizedDescription(nameof(Resources.SymmetricWireFormat_Classic))] + Classic = 0, + + /// + /// Same wire layout as but with the OWASP-recommended PBKDF2-HMAC-SHA1 + /// iteration count (1 300 000 by default; caller-overridable). Choose for new + /// UiPath-to-UiPath workflows that want current-best-practice security. The iteration + /// count is not carried in the ciphertext — encrypt and decrypt sides must use + /// matching values. Owasp2026 is a year-snapshot: if OWASP revises the + /// recommendation, this package adds a new enum entry (e.g. Owasp2030) rather + /// than changing the constant. + /// + [LocalizedDescription(nameof(Resources.SymmetricWireFormat_Owasp2026))] + Owasp2026 = 1, + + /// + /// Caller-supplied raw cipher key and (optionally) IV, no KDF. Wire layout: + /// IV ‖ ciphertext [‖ tag(16)]. Use for interop with tools that take a literal + /// cipher key — openssl enc -K <hex> -iv <hex>, Python + /// cryptography, Java javax.crypto, browser SubtleCrypto, KMS-managed keys. + /// NEVER reuse the same (Key, IV) pair across encryptions: under AEAD modes + /// (AES-GCM, ChaCha20-Poly1305) a single nonce collision lets an attacker recover the + /// authentication subkey and forge arbitrary messages under that key forever. + /// + [LocalizedDescription(nameof(Resources.SymmetricWireFormat_Raw))] + Raw = 2, + + /// + /// openssl enc-compatible layout: Salted__(8) ‖ salt(8) ‖ ciphertext [‖ tag(16)], + /// PBKDF2-HMAC-SHA256 with caller-overridable iteration count (default 600 000 — OWASP's + /// recommendation for SHA-256). Decryptable by openssl enc -d -pbkdf2 -iter <N> -md sha256. + /// AEAD over OpenSslEnc is a UiPath extension, not a cross-tool standard — combine + /// AESGCM / ChaCha20Poly1305 with this format only when both producer and + /// consumer are UiPath. + /// + [LocalizedDescription(nameof(Resources.SymmetricWireFormat_OpenSslEnc))] + OpenSslEnc = 3, + } +} diff --git a/Activities/Cryptography/docs/symmetric-wire-format.md b/Activities/Cryptography/docs/symmetric-wire-format.md index e6ca505ab..3f5dd5735 100644 --- a/Activities/Cryptography/docs/symmetric-wire-format.md +++ b/Activities/Cryptography/docs/symmetric-wire-format.md @@ -1,19 +1,59 @@ # UiPath.Cryptography symmetric ciphertext wire format This document describes the on-the-wire format produced by `EncryptText` / -`EncryptFile` and consumed by `DecryptText` / `DecryptFile` when using a -**symmetric** algorithm (`AES`, `AESGCM`, `ChaCha20Poly1305`, `DES`, `RC2`, -`Rijndael`, `TripleDES`). PGP is out of scope — it has its own format defined -by RFC 9580. +`EncryptFile` and consumed by `DecryptText` / `DecryptFile` for **symmetric** +algorithms (`AES`, `AESGCM`, `ChaCha20Poly1305`, `DES`, `RC2`, `Rijndael`, +`TripleDES`). PGP is out of scope — it has its own format defined by RFC 9580. -The format is **UiPath-specific**. Ciphertext produced by these activities is -**not directly compatible** with raw `openssl enc`, ServiceNow's encryption -APIs, browser-based tools such as `devglan.com`, or any other tool that does -not implement the layout below. +Each symmetric activity has a `Format` property selecting one of four wire +formats: -## Byte layout +| Format | KDF | Key input | Wire layout | Iterop with external tools | +| ------------- | -------------------------------- | ------------------------- | -------------------------------------------------------- | ------------------------------------------ | +| `Classic` | PBKDF2-HMAC-SHA1 @ **10 000** | Password (Encoding bytes) | `salt(8) ‖ IV ‖ ct [‖ tag]` | None — UiPath-specific, backward-compat. | +| `Owasp2026` | PBKDF2-HMAC-SHA1 @ caller-set | Password (Encoding bytes) | `salt(8) ‖ IV ‖ ct [‖ tag]` (same as Classic) | None — UiPath layout with OWASP 2026 KDF. | +| `Raw` | none | Raw bytes (Hex or Base64) | `IV ‖ ct [‖ tag]` | Yes — e.g. `openssl enc -K -iv `, Python `cryptography`, Java `javax.crypto`. | +| `OpenSslEnc` | PBKDF2-HMAC-SHA256 @ caller-set | Password (Encoding bytes) | `Salted__(8) ‖ salt(8) ‖ ct [‖ tag]` | Yes — `openssl enc -pbkdf2 -iter -md sha256 -salt -k `. | -The bytes returned by `CryptographyHelper.EncryptData` are arranged as: +**Default is `Classic`.** Existing workflows with no `Format` property set +behave byte-identically to every prior release of this package. Tracked in +[STUD-64429](https://uipath.atlassian.net/browse/STUD-64429). + +> **AEAD over `OpenSslEnc` is a UiPath extension, not a cross-tool standard.** +> `openssl enc` does not officially support GCM/Poly1305 modes; combine +> `AESGCM` / `ChaCha20Poly1305` with `OpenSslEnc` only when both producer and +> consumer are UiPath. + +## Choosing a format + +| Situation | Use | +| -------------------------------------------------------------------------------------- | -------------- | +| Reading or producing ciphertext that any older UiPath release will need to decrypt | `Classic` | +| New UiPath-to-UiPath workflow, want current-best-practice security | `Owasp2026` | +| Peer is `openssl enc -K -iv `, Python `cryptography`, Java `javax.crypto` | `Raw` | +| Peer is `openssl enc -pbkdf2 -k ` (or any tool that emits that layout) | `OpenSslEnc` | + +## Activity properties added with the four-format feature + +| Property | Type | Notes | +| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `Format` | `SymmetricWireFormat` | Enum, default `Classic`. Selects which of the four wire formats below. | +| `KeyFormat` | `KeyBytesFormat` | Enum, default `Encoded`. Required `Hex` or `Base64` when `Format = Raw`; rejected otherwise. | +| `Iv` | `InArgument` | Optional, only valid when `Format = Raw`. Parsed via `KeyFormat`. Empty → random IV generated. | +| `KdfIterations` | `InArgument` | Auto-fills with the format's shipped iteration count when `Format` is set to `Owasp2026` or `OpenSslEnc`. Override only for cross-tool interop. Minimum 1 000. Hidden / not used for `Classic` and `Raw`. | + +--- + +## Classic (default) + +Wire layout, per-algorithm sizes, and KDF parameters are fixed at the +historical UiPath values. **No changes are accepted to this format**: +backward compatibility with ciphertext produced by every previous release of +the package is non-negotiable. + +**Choose `Classic` when** you need to decrypt existing UiPath ciphertext, or +when you need to keep producing ciphertext that older UiPath builds can +decrypt without changes. ``` +-------------------+--------------------+----------------------+--------------------+ @@ -22,8 +62,7 @@ The bytes returned by `CryptographyHelper.EncryptData` are arranged as: +-------------------+--------------------+----------------------+--------------------+ ``` -`EncryptText` Base64-encodes the result before returning a string. -`EncryptFile` writes the raw bytes to the output file. +`EncryptText` Base64-encodes the result; `EncryptFile` writes raw bytes. ### Per-algorithm sizes @@ -37,33 +76,21 @@ The bytes returned by `CryptographyHelper.EncryptData` are arranged as: | `AESGCM` | GCM | 12 bytes | 32 bytes (256b) | 16 bytes | none | Yes | | `ChaCha20Poly1305` | AEAD-stream | 12 bytes | 32 bytes (256b) | 16 bytes | none | Yes | -For non-AEAD algorithms there is no tag — ciphertext occupies all remaining -bytes after `salt + IV`. For AEAD algorithms the final 16 bytes are the -authentication tag. - -## Key derivation - -The key the activity asks for is **not** used directly. It is run through -PBKDF2 to derive the actual algorithm key: +### KDF — Classic | Parameter | Value | | ------------- | -------------------------------------------------- | | Function | PBKDF2-HMAC-SHA1 (`Rfc2898DeriveBytes` default) | -| Iterations | 10 000 | -| Salt | 8 bytes from `RandomNumberGenerator.Create()` | +| Iterations | **10 000** (fixed) | +| Salt | 8 random bytes per call | | Output length | Maximum legal key size of the algorithm, in bytes | -The salt is generated freshly per call, which is **why two encryptions of the -same plaintext with the same key produce different ciphertexts**. This is -intentional — it protects the derived key against pre-computation attacks. - -## Encoding +> **Security note:** 10 000 PBKDF2-SHA1 iterations is the *floor* of +> acceptable values by current OWASP / NIST guidance, kept here only for +> backward compatibility. For new workflows use `Owasp2026` or `OpenSslEnc` with +> their OWASP-recommended defaults. -`EncryptText` / `DecryptText` first turn the plaintext string and the key -string into bytes using the activity's `Encoding` property (default UTF-8). -The byte arrays then enter `EncryptData` / `DecryptData` as described above. - -## Reference decoder — Python (non-AEAD: TripleDES, AES, DES, RC2, Rijndael) +### Reference decoder — Python (Classic, non-AEAD) ```python import base64 @@ -72,28 +99,23 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.padding import PKCS7 -# --- parameters: change to match the UiPath activity --- -ALGO = algorithms.TripleDES # AES / TripleDES / ... from cryptography.hazmat -KEY_LEN = 24 # see the per-algorithm table above (bytes) +ALGO = algorithms.TripleDES # AES / TripleDES / ... +KEY_LEN = 24 # see per-algorithm table IV_LEN = 8 # 8 for TripleDES/DES/RC2, 16 for AES/Rijndael KEY = "your-key-string".encode("utf-8") -CIPHER_B64 = "..." # output of EncryptText +CIPHER_B64 = "..." # output of EncryptText raw = base64.b64decode(CIPHER_B64) salt, iv, ct = raw[:8], raw[8:8+IV_LEN], raw[8+IV_LEN:] - -derived = PBKDF2HMAC( - algorithm=hashes.SHA1(), length=KEY_LEN, salt=salt, iterations=10_000 -).derive(KEY) +derived = PBKDF2HMAC(algorithm=hashes.SHA1(), length=KEY_LEN, salt=salt, iterations=10_000).derive(KEY) dec = Cipher(ALGO(derived), modes.CBC(iv)).decryptor() padded = dec.update(ct) + dec.finalize() unpadder = PKCS7(ALGO.block_size).unpadder() -plaintext = unpadder.update(padded) + unpadder.finalize() -print(plaintext.decode("utf-8")) +print((unpadder.update(padded) + unpadder.finalize()).decode("utf-8")) ``` -## Reference decoder — Python (AEAD: AES-GCM, ChaCha20-Poly1305) +### Reference decoder — Python (Classic, AEAD) ```python import base64 @@ -101,70 +123,289 @@ from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305 from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -# --- parameters: change to match the UiPath activity --- KEY = "your-key-string".encode("utf-8") -CIPHER_B64 = "..." # output of EncryptText +CIPHER_B64 = "..." USE_AESGCM = True # False => ChaCha20-Poly1305 raw = base64.b64decode(CIPHER_B64) SALT_LEN, IV_LEN, TAG_LEN = 8, 12, 16 -salt = raw[:SALT_LEN] -iv = raw[SALT_LEN:SALT_LEN + IV_LEN] -tag = raw[-TAG_LEN:] -ct = raw[SALT_LEN + IV_LEN : -TAG_LEN] - -derived = PBKDF2HMAC( - algorithm=hashes.SHA1(), length=32, salt=salt, iterations=10_000 -).derive(KEY) +salt, iv, tag = raw[:8], raw[8:20], raw[-16:] +ct = raw[20:-16] +derived = PBKDF2HMAC(algorithm=hashes.SHA1(), length=32, salt=salt, iterations=10_000).derive(KEY) aead = AESGCM(derived) if USE_AESGCM else ChaCha20Poly1305(derived) -plaintext = aead.decrypt(iv, ct + tag, None) # cryptography expects ct||tag -print(plaintext.decode("utf-8")) +print(aead.decrypt(iv, ct + tag, None).decode("utf-8")) +``` + +--- + +## Owasp2026 (UiPath layout, OWASP's 2026 guidance) + +Identical wire layout to `Classic` — only the PBKDF2 iteration count changes. +`KdfIterations` auto-fills with OWASP's 2026 password-storage recommendation +(**1 300 000** for PBKDF2-HMAC-SHA1) when this format is selected. Override +to any value ≥ 1 000. + +The year-suffix is intentional. `Owasp2026` is a **snapshot**, not a moving +target: when OWASP revises its guidance, a new enum value such as +`Owasp2030` will be added to `SymmetricWireFormat` with the updated default, +and `Owasp2026` will keep its defaults byte-stable forever. Users migrating +across UiPath releases pin a year explicitly; they never wake up to changed +ciphertext just because the package was upgraded. + +**Choose `Owasp2026` when** both endpoints are UiPath and you want +current-best-practice security. Classic and Owasp2026 produce the same wire +format, so an Owasp2026 blob encrypted with `KdfIterations = 10_000` is +byte-identical to a Classic blob — and decryption will succeed regardless of +which Format is selected on the consumer, as long as the iteration count +matches. + +The Python reference decoder above works unchanged; substitute +`iterations=` for the `10_000` literal. + +--- + +## Raw (caller-supplied key and IV) + +No KDF. Caller supplies raw cipher key bytes via `Hex` or `Base64`. IV is +prepended to the stream (random if not supplied, otherwise the caller's +value). + +**Choose `Raw` when** the peer is a tool that takes a literal cipher key +(rather than a password): `openssl enc -K -iv `, Python's +`cryptography` library, Java's `javax.crypto`, browser SubtleCrypto, etc. +Also the right choice when the key comes from a key-management system and +you don't want a KDF in the loop. + +> **⚠ NEVER reuse the same `(Key, IV)` pair across encryptions.** +> +> Nonce reuse with the same key is catastrophic in `Raw` mode: +> +> - **All modes (CTR-derived, including CBC and AEAD):** identical keystream +> on both messages → `C₁ ⊕ C₂ = P₁ ⊕ P₂` (the classic two-time-pad +> disaster). Plaintexts with any structure are usually recoverable from +> the XOR. +> - **AEAD (`AESGCM`, `ChaCha20Poly1305`) only — much worse:** a single +> nonce collision lets an attacker solve a polynomial over GF(2¹²⁸) and +> recover the GHASH/Poly1305 authentication subkey `H`. The subkey is +> derived from the cipher key alone (the nonce never touches it), so once +> `H` is recovered the attacker can forge a valid tag for **any** message +> under that key — including messages with fresh, never-reused nonces. +> The key is permanently compromised for authentication. +> +> The safe default: leave the `Iv` property empty so the cipher generates a +> cryptographically random IV per call. Supply an explicit IV only when the +> peer protocol mandates it, and ensure your producer guarantees nonce +> uniqueness (e.g. an atomic counter persisted across runs, or a 96-bit +> random nonce with documented birthday-bound analysis for your message +> volume). +> +> The `EncryptText` and `EncryptFile` activities raise a design-time +> warning when the `Iv` property is bound. The warning is informational — +> Studio will not block the workflow — but is meant to surface the +> constraint before the ciphertext ever leaves the machine. + +Layout: + +``` ++----------------+----------------------+-------------------+ +| IV | ciphertext | tag (AEAD only) | +| (block size) | (variable) | 16 bytes | ++----------------+----------------------+-------------------+ +``` + +This is what `openssl enc -aes-256-cbc -K -iv ` outputs, what +Python `cryptography` produces by default for `Cipher(...).encryptor()`, and +what Java `javax.crypto.Cipher` produces with a `SecretKeySpec` and explicit +`IvParameterSpec`. + +### openssl interop + +```bash +# Encrypt with openssl, decrypt in UiPath (Format = Raw, KeyFormat = Hex) +openssl rand -hex 32 > key.hex +openssl rand -hex 16 > iv.hex +openssl enc -aes-256-cbc -K "$(cat key.hex)" -iv "$(cat iv.hex)" -in plain.txt -out cipher.bin +# Concatenate IV + ciphertext to match UiPath Raw layout: +xxd -r -p iv.hex > stream.bin && cat cipher.bin >> stream.bin +# Base64-encode for DecryptText, or pass stream.bin to DecryptFile. + +# Encrypt in UiPath (Format = Raw, Algorithm = AES, KeyFormat = Hex), decrypt with openssl +# UiPath output = base64(IV‖ct). Decode and split: +base64 -d < uipath-output.txt > stream.bin +head -c 16 stream.bin > iv.bin +tail -c +17 stream.bin > cipher.bin +openssl enc -aes-256-cbc -d -K "$(cat key.hex)" -iv "$(xxd -p iv.bin | tr -d '\n')" -in cipher.bin +``` + +### Reference decoder — Python (Raw, AEAD) + +```python +import base64 +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +KEY = bytes.fromhex("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F") +CIPHER_B64 = "..." # output of EncryptText with Format=Raw + +raw = base64.b64decode(CIPHER_B64) +iv, ct_and_tag = raw[:12], raw[12:] +print(AESGCM(KEY).decrypt(iv, ct_and_tag, None).decode("utf-8")) ``` -## Reference encoder — Python (non-AEAD) +### Reference encoder — Python (Raw, AEAD) ```python import base64, os +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +KEY = bytes.fromhex("000102030405060708090A0B0C0D0E0F101112131415161718191A1B1C1D1E1F") +PLAIN = "hello".encode("utf-8") + +iv = os.urandom(12) +ct_and_tag = AESGCM(KEY).encrypt(iv, PLAIN, None) +print(base64.b64encode(iv + ct_and_tag).decode("ascii")) +``` + +--- + +## OpenSslEnc (openssl `enc` compatibility) + +**Choose `OpenSslEnc` when** the peer is `openssl enc -pbkdf2 -k ` +or any tool that emits the same layout. Match the peer's `-iter ` value +via `KdfIterations`. + +Wire layout matches the standard openssl 1.1.1+ output for `enc -pbkdf2 -salt`: + +``` ++----------------+-------------+----------------------+-------------------+ +| Salted__ | salt | ciphertext | tag (AEAD only) | +| (8 bytes) | (8 bytes) | (variable) | 16 bytes | ++----------------+-------------+----------------------+-------------------+ +``` + +`Salted__` is the literal ASCII string `S a l t e d _ _`. The salt is 8 +random bytes. PBKDF2-HMAC-SHA256 is run against the password to produce a +`key ‖ iv` block — the key is the first N bytes (algorithm key size), the IV +is the next M bytes (algorithm IV size). There is no separate IV in the +stream; both key and IV come from the KDF. + +### KDF — OpenSslEnc + +| Parameter | Value | +| ------------- | ------------------------------------------------------ | +| Function | PBKDF2-HMAC-SHA256 | +| Iterations | **600 000** (default — OWASP 2026 value for SHA-256) | +| Salt | 8 random bytes per call | +| Output length | algorithm key size + IV size | + +> openssl's *own* default is 10 000 iterations, kept by the openssl project +> for backward compatibility. UiPath defaults to the OWASP 2026 value +> instead. When interoperating with `openssl enc -iter `, set +> `KdfIterations` to match ``. Unlike `Owasp2026`, this format's default +> is *not* pinned to a year by name — future releases may revise the +> default. Pin `KdfIterations` explicitly if cross-version stability matters. + +### openssl interop + +```bash +# openssl encrypts, UiPath DecryptFile reads (Format = OpenSslEnc, KdfIterations = 600000) +openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -md sha256 -salt -k "your-password" \ + -in plain.txt -out cipher.bin + +# UiPath encrypts, openssl reads (matched iter count) +# DecryptText with Format = OpenSslEnc and the same password unpacks an openssl-produced blob. +openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -md sha256 -d -k "your-password" -in cipher.bin +``` + +### Reference decoder — Python (OpenSslEnc, non-AEAD) + +```python from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.padding import PKCS7 -ALGO = algorithms.TripleDES -KEY_LEN = 24 -IV_LEN = 8 -KEY = "your-key-string".encode("utf-8") -PLAIN = "hello".encode("utf-8") +with open("cipher.bin", "rb") as f: raw = f.read() +assert raw[:8] == b"Salted__" +salt = raw[8:16] +ct = raw[16:] -salt = os.urandom(8) -iv = os.urandom(IV_LEN) -derived = PBKDF2HMAC( - algorithm=hashes.SHA1(), length=KEY_LEN, salt=salt, iterations=10_000 -).derive(KEY) +KEY_LEN, IV_LEN, ITER = 32, 16, 600_000 +derived = PBKDF2HMAC(algorithm=hashes.SHA256(), length=KEY_LEN + IV_LEN, + salt=salt, iterations=ITER).derive(b"your-password") +key, iv = derived[:KEY_LEN], derived[KEY_LEN:] -padder = PKCS7(ALGO.block_size).padder() -padded = padder.update(PLAIN) + padder.finalize() -enc = Cipher(ALGO(derived), modes.CBC(iv)).encryptor() -ct = enc.update(padded) + enc.finalize() - -print(base64.b64encode(salt + iv + ct).decode("ascii")) +dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor() +padded = dec.update(ct) + dec.finalize() +unpadder = PKCS7(algorithms.AES.block_size).unpadder() +print((unpadder.update(padded) + unpadder.finalize()).decode("utf-8")) ``` +--- + +## KDF iterations and the `KdfIterations` property + +`KdfIterations` is exposed only for `Owasp2026` and `OpenSslEnc`. The field +auto-fills with the format's shipped value when you change `Format` (no +sentinel to memorise — what you see is what runs): + +| Format | KDF | Auto-filled value | Minimum when overridden | +| ------------ | ------------------ | ----------------- | ----------------------------------- | +| `Classic` | PBKDF2-HMAC-SHA1 | 10 000 (fixed) | property hidden, override rejected | +| `Owasp2026` | PBKDF2-HMAC-SHA1 | 1 300 000 | 1 000 (NIST SP 800-132 floor) | +| `Raw` | none | n/a | property hidden | +| `OpenSslEnc` | PBKDF2-HMAC-SHA256 | 600 000 | 1 000 | + +**The iteration count is not stored in the wire format.** Encrypt and decrypt +sides must agree externally. In particular: + +- An `Owasp2026` blob produced with `KdfIterations = 1_300_000` will fail + to decrypt with `KdfIterations = 10_000`, even though the wire layout is + identical. Cross-version-decrypt scenarios should set `KdfIterations` + explicitly on both sides. +- When OWASP revises its recommendation, the new defaults land as a new + enum value (e.g. `Owasp2030`); `Owasp2026` keeps its values forever. + Workflows that pin a specific year are immune to package upgrades. + +## Validation matrix + +The activity rejects inconsistent property combinations at runtime (wrapped +as `InvalidOperationException` with a localized message): + +| Constraint | Cause | +| ---------------------------------------------------------------- | -------------------------------------- | +| `Format = Raw` + `KeyFormat = Encoded` | Raw mode requires literal bytes (Hex or Base64). | +| `Format ∈ {Classic, Owasp2026, OpenSslEnc}` + `KeyFormat ≠ Encoded` | Password-based formats use Encoding. | +| `Iv` set + `Format ≠ Raw` | Other formats embed IV in the stream. | +| `KdfIterations ≠ 0` + `Format ∈ {Classic, Raw}` | Classic's iteration count is fixed; Raw has no KDF. | +| `KdfIterations < 0`, or `0 < KdfIterations < 1000` | Below NIST floor. Only `0` (the sentinel for "use the format's shipped value") is accepted under the floor. | +| `Format = Raw` + raw key length not legal for the algorithm | E.g. AES needs 16/24/32 bytes. | + +## Obsolete algorithms + +`AES`, `DES`, `RC2`, `Rijndael`, and `TripleDES` are marked `[Obsolete]` in +the `EncryptionAlgorithm` enum (the compiler raises CS0618). They remain +fully functional in every format — including for *encryption*, not just +decryption — to support customers whose legacy systems require interop with +e.g. TripleDES. The `[Obsolete]` warning is the user-facing security signal; +the activity does not impose a second runtime gate. + ## When you see "Decryption failed" in DecryptText / DecryptFile -The activity raises a `CryptographicException` with one of two hints when the -input cannot be parsed: +The activity raises a `CryptographicException` with a hint when the input +cannot be parsed: -- **"too short to be in UiPath wire format"** — the input did not contain - enough bytes for `salt + IV`. The input is almost certainly from a tool - that does not embed `salt + IV` in its output, or it has been truncated. +- **"too short to be in UiPath wire format"** — input is shorter than the + minimum prefix (salt+IV for Classic/Owasp2026, IV for Raw, Salted__+salt for + OpenSslEnc). Almost certainly produced by a tool with a different layout + or truncated in transit. +- **"does not start with the openssl 'Salted__' magic prefix"** — input is + not in OpenSslEnc format. Check the producer used `openssl enc -salt` and + the bytes were not transcoded. - **"this commonly indicates the input was produced by a different tool"** — - parsing succeeded, but the derived key did not unpad the ciphertext. The - most common causes are (a) the input came from a different tool with a - different wire format, (b) the key string or its encoding does not match - what was used to encrypt, or (c) the algorithm selected does not match - what was used to encrypt. - -Use the reference decoders above to verify that your input is in the UiPath -wire format with the correct key. + parsing succeeded but padding/tag verification failed. Common causes: + wrong Format selected, wrong key/encoding, wrong algorithm, or wrong + `KdfIterations` for Owasp2026/OpenSslEnc. + +Use the reference decoders above to verify your input matches the declared +format.