91 lines
4.1 KiB
C#
91 lines
4.1 KiB
C#
|
|
using System.Security.Cryptography;
|
||
|
|
using System.Security.Cryptography.X509Certificates;
|
||
|
|
using System.Text.Json;
|
||
|
|
|
||
|
|
namespace MusicBridge.Agent.Network;
|
||
|
|
|
||
|
|
internal sealed record TrustedDevice(string Id, string Name, string TokenHash, DateTimeOffset CreatedAt);
|
||
|
|
|
||
|
|
internal sealed class IdentityStore : IDisposable
|
||
|
|
{
|
||
|
|
private sealed record Identity(int Version, byte[] Certificate, TrustedDevice[] Devices);
|
||
|
|
private readonly FileStream owner;
|
||
|
|
private readonly string path;
|
||
|
|
private Identity identity = null!;
|
||
|
|
public X509Certificate2 Certificate { get; private set; } = null!;
|
||
|
|
public IReadOnlyList<TrustedDevice> Devices => identity.Devices;
|
||
|
|
public static string DefaultDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MusicBridge");
|
||
|
|
|
||
|
|
public IdentityStore(string directory)
|
||
|
|
{
|
||
|
|
Directory.CreateDirectory(directory);
|
||
|
|
path = Path.Combine(directory, "identity.dat");
|
||
|
|
// Prevent two agents from racing the certificate or overwriting trusted devices.
|
||
|
|
owner = new FileStream(Path.Combine(directory, "identity.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||
|
|
try
|
||
|
|
{
|
||
|
|
if (File.Exists(path))
|
||
|
|
{
|
||
|
|
if (new FileInfo(path).Length > 1024 * 1024) throw new InvalidDataException("Хранилище слишком большое.");
|
||
|
|
var clear = UserProtection.Unprotect(File.ReadAllBytes(path));
|
||
|
|
try { identity = JsonSerializer.Deserialize<Identity>(clear) ?? throw new InvalidDataException("Пустое хранилище."); }
|
||
|
|
finally { CryptographicOperations.ZeroMemory(clear); }
|
||
|
|
if (identity.Version != 1 || identity.Certificate is null || identity.Devices is null || identity.Devices.Length > 8
|
||
|
|
|| identity.Devices.Any(d => d is null || !Guid.TryParseExact(d.Id, "N", out _) || string.IsNullOrWhiteSpace(d.Name)
|
||
|
|
|| d.Name.Length > 64 || d.TokenHash is null || d.TokenHash.Length != 64 || !d.TokenHash.All(Uri.IsHexDigit))
|
||
|
|
|| identity.Devices.Select(d => d.Id).Distinct().Count() != identity.Devices.Length)
|
||
|
|
throw new InvalidDataException("Неверный формат хранилища.");
|
||
|
|
Certificate = X509CertificateLoader.LoadPkcs12(identity.Certificate, null);
|
||
|
|
if (!Certificate.HasPrivateKey || Certificate.NotAfter.ToUniversalTime() <= DateTime.UtcNow)
|
||
|
|
throw new InvalidDataException("Сертификат недоступен или истёк. Хранилище сохранено без изменений.");
|
||
|
|
}
|
||
|
|
else
|
||
|
|
{
|
||
|
|
Certificate = AgentCertificate.Create();
|
||
|
|
identity = new(1, Certificate.Export(X509ContentType.Pfx), []);
|
||
|
|
Save(identity);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
catch
|
||
|
|
{
|
||
|
|
Certificate?.Dispose();
|
||
|
|
if (identity?.Certificate is { } bytes) CryptographicOperations.ZeroMemory(bytes);
|
||
|
|
owner.Dispose();
|
||
|
|
throw;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
public void SaveDevices(TrustedDevice[] devices)
|
||
|
|
{
|
||
|
|
var updated = identity with { Devices = devices };
|
||
|
|
Save(updated); // Commit to memory only after the protected file is safely replaced.
|
||
|
|
identity = updated;
|
||
|
|
}
|
||
|
|
|
||
|
|
private void Save(Identity value)
|
||
|
|
{
|
||
|
|
var clear = JsonSerializer.SerializeToUtf8Bytes(value);
|
||
|
|
byte[] encrypted;
|
||
|
|
try { encrypted = UserProtection.Protect(clear); }
|
||
|
|
finally { CryptographicOperations.ZeroMemory(clear); }
|
||
|
|
var temporary = path + ".tmp";
|
||
|
|
try
|
||
|
|
{
|
||
|
|
using (var file = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
|
||
|
|
{
|
||
|
|
file.Write(encrypted);
|
||
|
|
file.Flush(flushToDisk: true);
|
||
|
|
}
|
||
|
|
File.Move(temporary, path, overwrite: true);
|
||
|
|
}
|
||
|
|
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||
|
|
}
|
||
|
|
|
||
|
|
public void Dispose()
|
||
|
|
{
|
||
|
|
Certificate.Dispose();
|
||
|
|
CryptographicOperations.ZeroMemory(identity.Certificate);
|
||
|
|
owner.Dispose();
|
||
|
|
}
|
||
|
|
}
|