Files
windows/Network/PairingService.cs
T

83 lines
3.3 KiB
C#
Raw Normal View History

using System.Security.Cryptography;
using System.Text;
namespace MusicBridge.Agent.Network;
internal sealed record PairingWindow(string Code, DateTimeOffset ExpiresAt);
internal sealed record PairingResult(bool Success, string? Token, string Code, string? DeviceId = null);
internal sealed class PairingService(TimeProvider? clock = null, IdentityStore? store = null)
{
private readonly TimeProvider clock = clock ?? TimeProvider.System;
private readonly object gate = new();
private TrustedDevice[] devices = store?.Devices.ToArray() ?? [];
public event Action? TrustChanged;
public IReadOnlyList<TrustedDevice> Devices { get { lock (gate) return devices.ToArray(); } }
private PairingWindow? window;
private int attempts;
public PairingWindow Open()
{
lock (gate)
{
attempts = 0;
return window = new(RandomNumberGenerator.GetInt32(100_000_000).ToString("D8"),
clock.GetUtcNow().AddMinutes(5));
}
}
public PairingWindow? Current
{
get
{
lock (gate)
return window is not null && window.ExpiresAt > clock.GetUtcNow() && attempts < 10 ? window : null;
}
}
public PairingResult Pair(string? code, string? deviceName = null)
{
lock (gate)
{
if (window is null || window.ExpiresAt <= clock.GetUtcNow() || attempts >= 10)
return new(false, null, "pairing_closed");
attempts++;
if (code is null || code.Length != 8 || !CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(code), Encoding.UTF8.GetBytes(window.Code)))
return new(false, null, "invalid_code");
if (deviceName is not null && (string.IsNullOrWhiteSpace(deviceName) || deviceName.Length > 64 || deviceName.Any(char.IsControl)))
return new(false, null, "invalid_name");
if (devices.Length >= 8) return new(false, null, "device_limit");
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
var device = new TrustedDevice(Guid.NewGuid().ToString("N"), deviceName?.Trim() ?? "iPhone",
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))), clock.GetUtcNow());
var updated = devices.Append(device).ToArray();
try { store?.SaveDevices(updated); }
catch (Exception) { return new(false, null, "storage_failed"); }
devices = updated;
window = null; // One successful pairing per locally opened window.
return new(true, token, "ok", device.Id);
}
}
public bool Authorize(string? token)
{
if (token is null || token.Length != 64) return false;
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
lock (gate) return devices.Any(candidate => CryptographicOperations.FixedTimeEquals(Convert.FromHexString(candidate.TokenHash), hash));
}
public bool Revoke(string id)
{
lock (gate)
{
var updated = devices.Where(d => d.Id != id).ToArray();
if (updated.Length == devices.Length) return false;
store?.SaveDevices(updated);
devices = updated;
}
TrustChanged?.Invoke();
return true;
}
}