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