diff --git a/backend.tests/DeviceTokenServiceTests.cs b/backend.tests/DeviceTokenServiceTests.cs new file mode 100644 index 0000000..da91b6d --- /dev/null +++ b/backend.tests/DeviceTokenServiceTests.cs @@ -0,0 +1,36 @@ +using backend.Devices; +using FluentAssertions; +using Xunit; + +namespace backend.tests; + +public class DeviceTokenServiceTests +{ + private readonly DeviceTokenService _svc = new(); + + [Fact] + public void Generate_returns_url_safe_token_of_expected_length() + { + var (plaintext, hash) = _svc.Generate(); + plaintext.Should().MatchRegex("^[A-Za-z0-9_-]+$"); + plaintext.Length.Should().BeGreaterThanOrEqualTo(40); + hash.Should().NotBeNullOrEmpty(); + hash.Should().NotBe(plaintext); + } + + [Fact] + public void Hash_is_deterministic_for_same_input() + { + var (plaintext, hash) = _svc.Generate(); + _svc.Hash(plaintext).Should().Be(hash); + } + + [Fact] + public void Verify_accepts_matching_plaintext_and_rejects_others() + { + var (plaintext, hash) = _svc.Generate(); + _svc.Verify(plaintext, hash).Should().BeTrue(); + _svc.Verify(plaintext + "x", hash).Should().BeFalse(); + _svc.Verify("totally-different", hash).Should().BeFalse(); + } +} diff --git a/backend/Devices/DeviceTokenService.cs b/backend/Devices/DeviceTokenService.cs new file mode 100644 index 0000000..cd6c4ae --- /dev/null +++ b/backend/Devices/DeviceTokenService.cs @@ -0,0 +1,36 @@ +using System.Security.Cryptography; +using System.Text; + +namespace backend.Devices; + +public class DeviceTokenService +{ + public (string Plaintext, string Hash) Generate() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + var plaintext = Base64UrlEncode(bytes); + return (plaintext, Hash(plaintext)); + } + + public string Hash(string plaintext) + { + Span hash = stackalloc byte[32]; + SHA256.HashData(Encoding.UTF8.GetBytes(plaintext), hash); + return Convert.ToHexString(hash); + } + + public bool Verify(string plaintext, string expectedHash) + { + var actual = Hash(plaintext); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(actual), + Encoding.UTF8.GetBytes(expectedHash)); + } + + private static string Base64UrlEncode(ReadOnlySpan bytes) + => Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); +}