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..]; + } +}