Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/Tests/FlowTests.cs
Original file line number Diff line number Diff line change
@@ -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<FlowTests>()
.Build();

var options = configuration.GetSection("Meta").Get<MetaOptions>();
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);
}
}
2 changes: 2 additions & 0 deletions src/Tests/Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" />
<PackageReference Include="ThisAssembly.Project" Version="2.0.14" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Resources" Version="2.0.14" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
Expand All @@ -42,6 +43,7 @@

<ItemGroup>
<Content Include="Content\**\*.*" CopyToOutputDirectory="PreserveNewest" />
<EmbeddedResource Include="rsa_public_key.pem" Kind="Text" />
<ProjectProperty Include="BundledNETCoreAppPackageVersion" />
</ItemGroup>

Expand Down
9 changes: 9 additions & 0 deletions src/Tests/rsa_public_key.pem
Original file line number Diff line number Diff line change
@@ -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-----
127 changes: 127 additions & 0 deletions src/WhatsApp/FlowMessage.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Represents an encrypted flow message exchanged with WhatsApp Business API.</summary>
public record EncryptedFlowData(
[property: JsonPropertyName("encrypted_flow_data")] string Data,
[property: JsonPropertyName("encrypted_aes_key")] string Key,
[property: JsonPropertyName("initial_vector")] string IV);

/// <summary>Parsed flow data containing decrypted JSON and AES key/IV.</summary>
public record FlowData(JsonElement Data, byte[] Key, byte[] IV);

/// <summary>Implements the flow message encryption and decryption for the WhatsApp Business API.</summary>
public class FlowCryptography : IDisposable
{
const int TagLengthBytes = 16;
const int StandardNonceLength = 12;

readonly RSA rsa;

/// <summary>Initializes the class with the provided RSA private key from options.</summary>
public FlowCryptography(IOptions<MetaOptions> options)
: this(Throw.IfNullOrEmpty(options.Value.PrivateKey, "PrivateKey"))
{
}

/// <summary>Initializes the class with the provided RSA private key in PEM format.</summary>
public FlowCryptography(string privatePem)
{
rsa = RSA.Create();
rsa.ImportFromPem(privatePem);
}

/// <summary>Initializes the class with the provided RSA private key in PEM format and a passphrase for decryption.</summary>
public FlowCryptography(string privatePem, string passphrase)
{
rsa = RSA.Create();
rsa.ImportFromEncryptedPem(privatePem, passphrase);
}

/// <summary>Disposes the inner RSA key.</summary>
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;
}

/// <summary>Decrypts the provided encrypted flow data into a <see cref="FlowData"/> object.</summary>
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<JsonElement>(Encoding.UTF8.GetString(plaintext));
return new FlowData(json, aesKey, iv);
}

/// <summary>Encrypts the provided flow data into a Base64-encoded string.</summary>
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);
}
}
15 changes: 6 additions & 9 deletions src/WhatsApp/MetaOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,18 @@ namespace Devlooped.WhatsApp;
/// </summary>
public class MetaOptions
{
/// <summary>
/// API version for messages, defaults to v22.0.
/// </summary>
/// <summary>API version for messages, defaults to v22.0.</summary>
[DefaultValue("v22.0")]
public string ApiVersion { get; set; } = "v22.0";

/// <summary>
/// Custom string used in the Meta App Dashboard for configuring the webhook.
/// </summary>
/// <summary>Optional private key if Flows endpoint data will be processed.</summary>
public string? PrivateKey { get; set; }

/// <summary>Custom string used in the Meta App Dashboard for configuring the webhook.</summary>
[Required(ErrorMessage = "Meta:VerifyToken is required to properly register with WhatsApp for Business webhooks.")]
public required string VerifyToken { get; set; }

/// <summary>
/// Contains pairs of number ID > access token for WhatsApp for Business phone numbers.
/// </summary>
/// <summary>Contains pairs of number ID > access token for WhatsApp for Business phone numbers.</summary>
[MinLength(1, ErrorMessage = "At least one number ID > access token pair is required, i.e. Meta:Numbers:12345=asdf")]
public IDictionary<string, string> Numbers { get; set; } = new Dictionary<string, string>();
}
1 change: 1 addition & 0 deletions src/WhatsApp/WhatsApp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="NuGetizer" Version="1.3.0" />
<PackageReference Include="PolySharp" Version="1.15.0" />
<PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />
<PackageReference Include="ThisAssembly.AssemblyInfo" Version="2.0.14" PrivateAssets="all" />
<PackageReference Include="ThisAssembly.Resources" Version="2.0.14" PrivateAssets="all" />
<PackageReference Include="Ulid " Version="1.3.4" />
Expand Down