83 lines
3.3 KiB
C#
83 lines
3.3 KiB
C#
|
|
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;
|
||
|
|
}
|
||
|
|
}
|