From db5c4e59a44ea2a29744cdf31edfc1193cb937da Mon Sep 17 00:00:00 2001 From: Assistant builder Date: Thu, 11 Jun 2026 18:53:32 +0000 Subject: [PATCH] Add PairingService with safe alphabet and 10-min TTL --- backend.tests/PairingServiceTests.cs | 28 ++++++++++ backend/Pairing/PairingService.cs | 84 ++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 backend.tests/PairingServiceTests.cs create mode 100644 backend/Pairing/PairingService.cs diff --git a/backend.tests/PairingServiceTests.cs b/backend.tests/PairingServiceTests.cs new file mode 100644 index 0000000..3fbcab8 --- /dev/null +++ b/backend.tests/PairingServiceTests.cs @@ -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); + } +} diff --git a/backend/Pairing/PairingService.cs b/backend/Pairing/PairingService.cs new file mode 100644 index 0000000..1de6de9 --- /dev/null +++ b/backend/Pairing/PairingService.cs @@ -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 bytes = stackalloc byte[CodeLength]; + RandomNumberGenerator.Fill(bytes); + Span 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 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 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); + } +}