Add PairingService with safe alphabet and 10-min TTL

This commit is contained in:
2026-06-11 18:53:32 +00:00
parent 720583de0a
commit db5c4e59a4
2 changed files with 112 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
using backend.Pairing;
using FluentAssertions;
using Xunit;
namespace backend.tests;
public class PairingServiceTests
{
[Fact]
public void Generated_codes_use_only_safe_alphabet_and_are_8_chars()
{
var svc = new PairingService();
for (var i = 0; i < 50; i++)
{
var code = svc.GenerateCode();
code.Length.Should().Be(8);
code.Should().MatchRegex("^[A-HJ-KM-NP-Z2-9]+$");
}
}
[Fact]
public void Generated_codes_are_not_all_identical()
{
var svc = new PairingService();
var codes = Enumerable.Range(0, 20).Select(_ => svc.GenerateCode()).ToHashSet();
codes.Count.Should().BeGreaterThan(15);
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Security.Cryptography;
using backend.Data;
using backend.Devices;
using Microsoft.EntityFrameworkCore;
namespace backend.Pairing;
public record PairResult(Guid DeviceId, string DeviceToken);
public class PairingService
{
private const string Alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O, 0, I, 1, L
private const int CodeLength = 8;
public static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(10);
private readonly AppDbContext? _db;
private readonly DeviceTokenService? _tokens;
public PairingService() { }
public PairingService(AppDbContext db, DeviceTokenService tokens)
{
_db = db;
_tokens = tokens;
}
public string GenerateCode()
{
Span<byte> bytes = stackalloc byte[CodeLength];
RandomNumberGenerator.Fill(bytes);
Span<char> chars = stackalloc char[CodeLength];
for (var i = 0; i < CodeLength; i++)
chars[i] = Alphabet[bytes[i] % Alphabet.Length];
return new string(chars);
}
public async Task<PairingCode> IssueAsync(Guid ownerUserId, string deviceName, CancellationToken ct)
{
if (_db is null) throw new InvalidOperationException("PairingService not constructed with DB context.");
var entity = new PairingCode
{
Code = GenerateCode(),
OwnerUserId = ownerUserId,
DeviceName = deviceName,
ExpiresAt = DateTimeOffset.UtcNow.Add(CodeTtl),
};
_db.PairingCodes.Add(entity);
await _db.SaveChangesAsync(ct);
return entity;
}
public async Task<PairResult?> ConsumeAsync(string code, CancellationToken ct)
{
if (_db is null || _tokens is null)
throw new InvalidOperationException("PairingService not constructed with DB context + token service.");
var now = DateTimeOffset.UtcNow;
var pc = await _db.PairingCodes.FirstOrDefaultAsync(p => p.Code == code, ct);
if (pc is null || pc.ConsumedAt is not null || pc.ExpiresAt < now)
return null;
var (plaintext, hash) = _tokens.Generate();
var defaults = await _db.SystemSettings.FirstAsync(ct);
var device = new Device
{
OwnerUserId = pc.OwnerUserId,
Name = pc.DeviceName,
PairedAt = now,
TokenHash = hash,
};
device.Config = new DeviceConfig
{
DeviceId = device.Id,
SystemPrompt = defaults.DefaultSystemPrompt,
Voice = defaults.DefaultVoice,
Model = defaults.DefaultModel,
IdleTimeoutSeconds = defaults.DefaultIdleTimeoutSeconds,
};
_db.Devices.Add(device);
pc.ConsumedAt = now;
await _db.SaveChangesAsync(ct);
return new PairResult(device.Id, plaintext);
}
}