From 5e2fbe800c12c8c2478eadbd8bbaaa510451f4a7 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 16:47:49 -0700 Subject: [PATCH] feat(1099): add ITinProtector with Data Protection encryption + last-4 helper Co-Authored-By: Claude Opus 4.8 --- API/ROLAC.API.Tests/ROLAC.API.Tests.csproj | 1 + .../Services/TinProtectorTests.cs | 30 +++++++++++++++++++ API/ROLAC.API/Program.cs | 3 ++ .../Services/Security/ITinProtector.cs | 8 +++++ .../Services/Security/TinProtector.cs | 22 ++++++++++++++ 5 files changed, 64 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/TinProtectorTests.cs create mode 100644 API/ROLAC.API/Services/Security/ITinProtector.cs create mode 100644 API/ROLAC.API/Services/Security/TinProtector.cs diff --git a/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj b/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj index ffa3eaa..59db6a3 100644 --- a/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj +++ b/API/ROLAC.API.Tests/ROLAC.API.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/API/ROLAC.API.Tests/Services/TinProtectorTests.cs b/API/ROLAC.API.Tests/Services/TinProtectorTests.cs new file mode 100644 index 0000000..44ba817 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/TinProtectorTests.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.DataProtection; +using ROLAC.API.Services.Security; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class TinProtectorTests +{ + private static TinProtector Build() => + new TinProtector(DataProtectionProvider.Create("ROLAC.Tests")); + + [Fact] + public void Protect_then_Unprotect_round_trips() + { + var p = Build(); + var cipher = p.Protect("123-45-6789"); + Assert.NotEqual("123-45-6789", cipher); + Assert.Equal("123-45-6789", p.Unprotect(cipher)); + } + + [Theory] + [InlineData("123-45-6789", "6789")] + [InlineData("12-3456789", "6789")] + [InlineData("7", "7")] + public void Last4_keeps_only_trailing_digits(string raw, string expected) + => Assert.Equal(expected, TinProtector.Last4(raw)); + + [Fact] + public void Last4_of_null_is_null() => Assert.Null(TinProtector.Last4(null)); +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 8f701c3..4e2bbf5 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -15,6 +15,7 @@ using ROLAC.API.Json; using ROLAC.API.Middleware; using ROLAC.API.Services; using ROLAC.API.Services.Logging; +using ROLAC.API.Services.Security; var builder = WebApplication.CreateBuilder(args); var config = builder.Configuration; @@ -157,6 +158,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddDataProtection(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/API/ROLAC.API/Services/Security/ITinProtector.cs b/API/ROLAC.API/Services/Security/ITinProtector.cs new file mode 100644 index 0000000..2996bd3 --- /dev/null +++ b/API/ROLAC.API/Services/Security/ITinProtector.cs @@ -0,0 +1,8 @@ +namespace ROLAC.API.Services.Security; + +/// Reversible protection for taxpayer identification numbers (SSN/EIN). +public interface ITinProtector +{ + string Protect(string plaintext); + string Unprotect(string ciphertext); +} diff --git a/API/ROLAC.API/Services/Security/TinProtector.cs b/API/ROLAC.API/Services/Security/TinProtector.cs new file mode 100644 index 0000000..f29a399 --- /dev/null +++ b/API/ROLAC.API/Services/Security/TinProtector.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.DataProtection; + +namespace ROLAC.API.Services.Security; + +public class TinProtector : ITinProtector +{ + private readonly IDataProtector _protector; + + public TinProtector(IDataProtectionProvider provider) + => _protector = provider.CreateProtector("Payee1099.Tin"); + + public string Protect(string plaintext) => _protector.Protect(plaintext); + public string Unprotect(string ciphertext) => _protector.Unprotect(ciphertext); + + /// Last four digits of a TIN (ignoring dashes/spaces); null/empty in => null. + public static string? Last4(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + var digits = new string(raw.Where(char.IsDigit).ToArray()); + return digits.Length <= 4 ? digits : digits[^4..]; + } +}