using System.Security.AccessControl; using System.Security.Cryptography; using System.Security.Principal; using System.Text; using System.Text.Json; namespace Zcbot.WindowsNode; internal sealed class NodeConfigStore(NodePaths paths) { private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("zcbot.windows-node.v1"); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; internal bool Exists => File.Exists(paths.ConfigPath); internal string ConfigPath => paths.ConfigPath; internal void Save(NodeConfig config) { Directory.CreateDirectory(paths.RootDirectory); RestrictDirectory(paths.RootDirectory); var protectedToken = ProtectedData.Protect( Encoding.UTF8.GetBytes(config.NodeToken), Entropy, DataProtectionScope.LocalMachine); var stored = new StoredNodeConfig( config.ServerUrl.AbsoluteUri, config.NodeId, config.InstallId, config.NodeName, Convert.ToBase64String(protectedToken), config.HeartbeatSeconds, config.Capabilities); var temporaryPath = paths.ConfigPath + ".tmp"; File.WriteAllText(temporaryPath, JsonSerializer.Serialize(stored, JsonOptions), Encoding.UTF8); RestrictFile(temporaryPath); File.Move(temporaryPath, paths.ConfigPath, overwrite: false); RestrictFile(paths.ConfigPath); } internal NodeConfig Load() { if (!Exists) { throw new NodeConfigurationException( "Node is not registered. Run the enroll command first."); } try { var stored = JsonSerializer.Deserialize( File.ReadAllText(paths.ConfigPath, Encoding.UTF8)) ?? throw new NodeConfigurationException("Node configuration is empty."); var token = Encoding.UTF8.GetString(ProtectedData.Unprotect( Convert.FromBase64String(stored.ProtectedNodeToken), Entropy, DataProtectionScope.LocalMachine)); if (stored.NodeId == Guid.Empty || stored.InstallId == Guid.Empty || string.IsNullOrWhiteSpace(stored.NodeName) || string.IsNullOrWhiteSpace(token) || stored.Capabilities.Count == 0 || stored.Capabilities.Any(item => item != "origin.plot@v2")) { throw new NodeConfigurationException("Node configuration contains an invalid identity or capability."); } return new NodeConfig( NodeUri.NormalizeServerUrl(stored.ServerUrl), stored.NodeId, stored.InstallId, stored.NodeName, token, Math.Clamp(stored.HeartbeatSeconds, 5, 300), stored.Capabilities); } catch (NodeConfigurationException) { throw; } catch (Exception exception) when ( exception is CryptographicException or FormatException or IOException or JsonException or UnauthorizedAccessException) { throw new NodeConfigurationException( $"Node configuration cannot be loaded: {exception.Message}"); } } internal void DeleteLocalIdentity() { if (File.Exists(paths.ConfigPath)) { File.Delete(paths.ConfigPath); } var temporaryPath = paths.ConfigPath + ".tmp"; if (File.Exists(temporaryPath)) { File.Delete(temporaryPath); } } private static void RestrictDirectory(string path) { var identity = WindowsIdentity.GetCurrent(); var user = identity.User ?? throw new NodeConfigurationException("Current Windows account has no security identifier."); var security = new DirectorySecurity(); security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); security.AddAccessRule(new FileSystemAccessRule( user, FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); security.AddAccessRule(new FileSystemAccessRule( new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), FileSystemRights.FullControl, InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); new DirectoryInfo(path).SetAccessControl(security); } private static void RestrictFile(string path) { var identity = WindowsIdentity.GetCurrent(); var user = identity.User ?? throw new NodeConfigurationException("Current Windows account has no security identifier."); var security = new FileSecurity(); security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); security.AddAccessRule(new FileSystemAccessRule(user, FileSystemRights.FullControl, AccessControlType.Allow)); security.AddAccessRule(new FileSystemAccessRule( new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), FileSystemRights.FullControl, AccessControlType.Allow)); new FileInfo(path).SetAccessControl(security); } }