Add DeviceTokenService (random token, SHA-256 hash, fixed-time verify)
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace backend.Devices;
|
||||||
|
|
||||||
|
public class DeviceTokenService
|
||||||
|
{
|
||||||
|
public (string Plaintext, string Hash) Generate()
|
||||||
|
{
|
||||||
|
Span<byte> bytes = stackalloc byte[32];
|
||||||
|
RandomNumberGenerator.Fill(bytes);
|
||||||
|
var plaintext = Base64UrlEncode(bytes);
|
||||||
|
return (plaintext, Hash(plaintext));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Hash(string plaintext)
|
||||||
|
{
|
||||||
|
Span<byte> 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<byte> bytes)
|
||||||
|
=> Convert.ToBase64String(bytes)
|
||||||
|
.TrimEnd('=')
|
||||||
|
.Replace('+', '-')
|
||||||
|
.Replace('/', '_');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user