diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs new file mode 100644 index 0000000..a7e1203 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs @@ -0,0 +1,47 @@ +using System.Security.Cryptography; +using System.Text; +using ROLAC.API.Services.Notifications; +using Xunit; + +namespace ROLAC.API.Tests.Services.Notifications; + +public class LineSignatureTests +{ + private const string Secret = "test-channel-secret"; + + private static string Sign(string body) + { + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret)); + return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body))); + } + + [Fact] + public void IsValid_ReturnsTrue_ForMatchingSignature() + { + var body = """{"events":[]}"""; + var signature = Sign(body); + + var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature); + + Assert.True(result); + } + + [Fact] + public void IsValid_ReturnsFalse_ForTamperedBody() + { + var signature = Sign("""{"events":[]}"""); + + var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature); + + Assert.False(result); + } + + [Fact] + public void IsValid_ReturnsFalse_ForNullOrEmptyHeader() + { + var body = Encoding.UTF8.GetBytes("""{"events":[]}"""); + + Assert.False(LineSignature.IsValid(Secret, body, null)); + Assert.False(LineSignature.IsValid(Secret, body, "")); + } +} diff --git a/API/ROLAC.API/Services/Notifications/LineSignature.cs b/API/ROLAC.API/Services/Notifications/LineSignature.cs new file mode 100644 index 0000000..5b8130b --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/LineSignature.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ROLAC.API.Services.Notifications; + +/// Verifies the X-Line-Signature header (HMAC-SHA256 of the raw body, base64). +public static class LineSignature +{ + public static bool IsValid(string channelSecret, byte[] rawBody, string? signatureHeader) + { + if (string.IsNullOrEmpty(signatureHeader)) return false; + + using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(channelSecret)); + var expected = Convert.ToBase64String(hmac.ComputeHash(rawBody)); + + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(signatureHeader)); + } +}