diff --git a/src/Tests/FlowTests.cs b/src/Tests/FlowTests.cs new file mode 100644 index 0000000..e0ee08a --- /dev/null +++ b/src/Tests/FlowTests.cs @@ -0,0 +1,37 @@ +using System.Text.Json; +using Microsoft.Extensions.Configuration; + +namespace Devlooped.WhatsApp; + +public class FlowTests(ITestOutputHelper output) +{ + [SecretsFact("Meta:PrivateKey")] + public void VerifyFlowRequest() + { + var configuration = new ConfigurationBuilder() + .AddUserSecrets() + .Build(); + + var options = configuration.GetSection("Meta").Get(); + Assert.NotNull(options?.PrivateKey); + + var crypto = new FlowCryptography(options.PrivateKey); + + var request = new EncryptedFlowData( + "0zMVQ5xXwGZ8nViXojCYovFRvrTB3dx2bDA4AhXoPhFirbNlsN9Gi7JYDDoBZ44W6g==", + @"e5JGMhHduIeaynRKPzleeZdcybczOJnTbLZ0nB0wWLYak1IkNbb06ZDNKt29h9A7wCOAJnf3DaWzWR5365z70QMgtN5oZRWkVEJgzNtIsM7vgbT2TZtVTLXuSNQrS4ueqF7s/d6WKLqhdz3+Ab2kebJlFoDbXQxMqVI2HK8qd5jI0lPIALp28tORq+Z3etz3qYW8p1K4ruc77LqYHrdF1YePLES+c5F90WQMt7gtbJMCMoQFPhViKXVOykJ0gChvqCxfu2wH/L0vU9HdhOFK2rZPxq123BvmLCLwSFt+CnQY64iambrTZXz4Z+GhtSCR9O8MBck6mDl9eWT/RAkxbg==", + "v1U9tB6hUBd4lVDUlaviBg=="); + + var decrypted = crypto.Decrypt(request); + Assert.NotNull(decrypted); + output.WriteLine(decrypted.Data.ToString()); + + var response = new { screen = "SCREEN_NAME", data = new { some_key = "some_value" } }; + var encrypted = crypto.Encrypt(new FlowData( + JsonSerializer.SerializeToElement(response), + decrypted.Key, + decrypted.IV)); + + Assert.NotEmpty(encrypted); + } +} diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index e114cbd..507246e 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -28,6 +28,7 @@ + @@ -42,6 +43,7 @@ + diff --git a/src/Tests/rsa_public_key.pem b/src/Tests/rsa_public_key.pem new file mode 100644 index 0000000..a8e9a3e --- /dev/null +++ b/src/Tests/rsa_public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0K3bWN26lphj/LG4DNvf +b6hhjMIFshT/2jSZl//TM+Eg7x1NDuBrPMxdHDWWfdo8eH9J0/SdzdkxFYARRIK7 +8KsSSDJNzrNLwmA+JRckEpTS0NXyqnBsFV1wi8ekou/JorfNCPJQpmB9+RFKwGb+ +/BbwrZa7YApNbbKiUdsdll3opm0GMnpCDTbMSXjsKMeUdqZhMFsxKhmR759YHciu +0AQLTJtdJjnBbK+m19opt1LPtGHgcC6eWHZCRd41srAJmAiNVvTF+jukRWcCgWeM +xauIpcWFjGOxvEOal04dDaN82Zauz6gmvXl1y/hqKuFlzlb7tq7aW8IdxMtM+hKZ ++QIDAQAB +-----END PUBLIC KEY----- diff --git a/src/WhatsApp/FlowMessage.cs b/src/WhatsApp/FlowMessage.cs new file mode 100644 index 0000000..c635df6 --- /dev/null +++ b/src/WhatsApp/FlowMessage.cs @@ -0,0 +1,127 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Options; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Modes; +using Org.BouncyCastle.Crypto.Parameters; + +namespace Devlooped.WhatsApp; + +/// Represents an encrypted flow message exchanged with WhatsApp Business API. +public record EncryptedFlowData( + [property: JsonPropertyName("encrypted_flow_data")] string Data, + [property: JsonPropertyName("encrypted_aes_key")] string Key, + [property: JsonPropertyName("initial_vector")] string IV); + +/// Parsed flow data containing decrypted JSON and AES key/IV. +public record FlowData(JsonElement Data, byte[] Key, byte[] IV); + +/// Implements the flow message encryption and decryption for the WhatsApp Business API. +public class FlowCryptography : IDisposable +{ + const int TagLengthBytes = 16; + const int StandardNonceLength = 12; + + readonly RSA rsa; + + /// Initializes the class with the provided RSA private key from options. + public FlowCryptography(IOptions options) + : this(Throw.IfNullOrEmpty(options.Value.PrivateKey, "PrivateKey")) + { + } + + /// Initializes the class with the provided RSA private key in PEM format. + public FlowCryptography(string privatePem) + { + rsa = RSA.Create(); + rsa.ImportFromPem(privatePem); + } + + /// Initializes the class with the provided RSA private key in PEM format and a passphrase for decryption. + public FlowCryptography(string privatePem, string passphrase) + { + rsa = RSA.Create(); + rsa.ImportFromEncryptedPem(privatePem, passphrase); + } + + /// Disposes the inner RSA key. + public void Dispose() => rsa.Dispose(); + + // Single decryption attempt with provided nonce. + static byte[] DecryptOnce(byte[] key, byte[] nonce, byte[] input) + { + var gcm = new GcmBlockCipher(new Org.BouncyCastle.Crypto.Engines.AesEngine()); + var parameters = new AeadParameters(new KeyParameter(key), TagLengthBytes * 8, nonce); + gcm.Init(false, parameters); + var plain = new byte[gcm.GetOutputSize(input.Length)]; + var len = gcm.ProcessBytes(input, 0, input.Length, plain, 0); + len += gcm.DoFinal(plain, len); + if (len != plain.Length) + Array.Resize(ref plain, len); + return plain; + } + + // Tries full IV first; on auth failure retries with truncated 12-byte nonce for backward compatibility. + static byte[] AesGcmDecrypt(byte[] key, byte[] iv, byte[] input) + { + try + { + return DecryptOnce(key, iv, input); + } + catch (InvalidCipherTextException) when (iv.Length >= StandardNonceLength) + { + var truncated = new byte[StandardNonceLength]; + Array.Copy(iv, 0, truncated, 0, StandardNonceLength); + return DecryptOnce(key, truncated, input); + } + } + + static byte[] AesGcmEncrypt(byte[] key, byte[] iv, byte[] plain) + { + if (iv.Length < StandardNonceLength) + throw new ArgumentException("IV must be at least 12 bytes."); + + var gcm = new GcmBlockCipher(new Org.BouncyCastle.Crypto.Engines.AesEngine()); + var parameters = new AeadParameters(new KeyParameter(key), TagLengthBytes * 8, iv); + gcm.Init(true, parameters); + var cipher = new byte[gcm.GetOutputSize(plain.Length)]; + var len = gcm.ProcessBytes(plain, 0, plain.Length, cipher, 0); + len += gcm.DoFinal(cipher, len); + if (len != cipher.Length) + Array.Resize(ref cipher, len); + return cipher; + } + + // Encapsulated IV bit-flip transformation used for nonce derivation during encryption. + static byte[] FlipIvBits(byte[] iv) + { + var flipped = new byte[iv.Length]; + for (int i = 0; i < iv.Length; i++) + flipped[i] = (byte)~iv[i]; + return flipped; + } + + /// Decrypts the provided encrypted flow data into a object. + public FlowData Decrypt(EncryptedFlowData data) + { + // Inline decode & decrypt pipeline (Base64 -> RSA -> AES-GCM -> JSON) + var aesKey = rsa.Decrypt(Convert.FromBase64String(data.Key), RSAEncryptionPadding.OaepSHA256); + var iv = Convert.FromBase64String(data.IV); + var cipher = Convert.FromBase64String(data.Data); + var plaintext = AesGcmDecrypt(aesKey, iv, cipher); + var json = JsonSerializer.Deserialize(Encoding.UTF8.GetString(plaintext)); + return new FlowData(json, aesKey, iv); + } + + /// Encrypts the provided flow data into a Base64-encoded string. + public string Encrypt(FlowData data) + { + // Derive nonce via bit-flip (encapsulated) and serialize JSON directly to UTF-8 bytes. + var flippedIv = FlipIvBits(data.IV); + var payload = JsonSerializer.SerializeToUtf8Bytes(data.Data); + var cipherWithTag = AesGcmEncrypt(data.Key, flippedIv, payload); + return Convert.ToBase64String(cipherWithTag); + } +} \ No newline at end of file diff --git a/src/WhatsApp/MetaOptions.cs b/src/WhatsApp/MetaOptions.cs index ee4366f..ecea04c 100644 --- a/src/WhatsApp/MetaOptions.cs +++ b/src/WhatsApp/MetaOptions.cs @@ -8,21 +8,18 @@ namespace Devlooped.WhatsApp; /// public class MetaOptions { - /// - /// API version for messages, defaults to v22.0. - /// + /// API version for messages, defaults to v22.0. [DefaultValue("v22.0")] public string ApiVersion { get; set; } = "v22.0"; - /// - /// Custom string used in the Meta App Dashboard for configuring the webhook. - /// + /// Optional private key if Flows endpoint data will be processed. + public string? PrivateKey { get; set; } + + /// Custom string used in the Meta App Dashboard for configuring the webhook. [Required(ErrorMessage = "Meta:VerifyToken is required to properly register with WhatsApp for Business webhooks.")] public required string VerifyToken { get; set; } - /// - /// Contains pairs of number ID > access token for WhatsApp for Business phone numbers. - /// + /// Contains pairs of number ID > access token for WhatsApp for Business phone numbers. [MinLength(1, ErrorMessage = "At least one number ID > access token pair is required, i.e. Meta:Numbers:12345=asdf")] public IDictionary Numbers { get; set; } = new Dictionary(); } diff --git a/src/WhatsApp/WhatsApp.csproj b/src/WhatsApp/WhatsApp.csproj index 0cc8d71..ca50974 100644 --- a/src/WhatsApp/WhatsApp.csproj +++ b/src/WhatsApp/WhatsApp.csproj @@ -37,6 +37,7 @@ +