From 85bf329d9323a955a34fa96784e9f529d041913b Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 19:07:01 -0700 Subject: [PATCH] Add Line webhook signature verification helper Implements LineSignature.IsValid() using HMAC-SHA256 + FixedTimeEquals to prevent timing attacks; includes xUnit tests for valid, tampered, and null/empty header cases. Co-Authored-By: Claude Opus 4.8 --- .../Notifications/LineSignatureTests.cs | 47 +++++++++++++++++++ .../Services/Notifications/LineSignature.cs | 20 ++++++++ 2 files changed, 67 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs create mode 100644 API/ROLAC.API/Services/Notifications/LineSignature.cs 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)); + } +}