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 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(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(); } }