From 2725751fbfe7c87c8cf7688cdc7acc2d0d8010f1 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:50:33 -0400 Subject: [PATCH 01/35] Add direct peer discovery contracts --- .../Shared/Mesh/PeerDiscoveryContracts.cs | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 MagicControl/Shared/Mesh/PeerDiscoveryContracts.cs diff --git a/MagicControl/Shared/Mesh/PeerDiscoveryContracts.cs b/MagicControl/Shared/Mesh/PeerDiscoveryContracts.cs new file mode 100644 index 0000000..53e6675 --- /dev/null +++ b/MagicControl/Shared/Mesh/PeerDiscoveryContracts.cs @@ -0,0 +1,22 @@ +using MagicSettings.Share; + +namespace MagicControl.Shared.Mesh; + +public sealed record MagicControlPeerAdvertisement( + int ProtocolVersion, + Guid GroupId, + string ApplicationName, + string DisplayName, + string? InstanceName, + string? InstanceRole, + string? SiteName, + string? Version, + MagicNodeIdentityDescriptor Identity, + IReadOnlyList Endpoints, + long Sequence, + DateTimeOffset IssuedUtc, + int TimeToLiveSeconds = 20); + +public sealed record SignedMagicControlPeerAdvertisement( + MagicControlPeerAdvertisement Advertisement, + MagicAuthenticationProof Proof); From a2ea4626135438b668b7e09772dfcc3935fd22bd Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:50:51 -0400 Subject: [PATCH 02/35] Add peer discovery protocol constants --- MagicControl/Shared/Mesh/MagicControlNodeProtocol.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/MagicControl/Shared/Mesh/MagicControlNodeProtocol.cs b/MagicControl/Shared/Mesh/MagicControlNodeProtocol.cs index 1c73bd1..d0ff12a 100644 --- a/MagicControl/Shared/Mesh/MagicControlNodeProtocol.cs +++ b/MagicControl/Shared/Mesh/MagicControlNodeProtocol.cs @@ -4,7 +4,13 @@ public static class MagicControlNodeProtocol { public const string NodeSyncAudience = "MagicControl.Node.Sync"; public const string NodeSecretAudience = "MagicControl.Node.Secret"; + public const string DiscoveryMulticastAddress = "239.255.77.77"; public const int DiscoveryPort = 45873; public const int DiscoveryProtocolVersion = 1; + + public const string PeerDiscoveryAudience = "MagicControl.Peer.Discovery"; + public const string PeerDiscoveryMulticastAddress = "239.255.77.78"; + public const int PeerDiscoveryPort = 45874; + public const int PeerDiscoveryProtocolVersion = 1; } From 5deda9c9e4df738abc822d189d4a24faa3a1938e Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:52:16 -0400 Subject: [PATCH 03/35] Configure direct peer discovery --- .../MagicControlClientOptions.cs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/MagicControl/Client/Configuration/MagicControlClientOptions.cs b/MagicControl/Client/Configuration/MagicControlClientOptions.cs index b206446..153e5f8 100644 --- a/MagicControl/Client/Configuration/MagicControlClientOptions.cs +++ b/MagicControl/Client/Configuration/MagicControlClientOptions.cs @@ -31,6 +31,7 @@ public sealed class MagicControlClientOptions public string StatePath { get; set; } = "state/magic-control"; public string ManifestFileName { get; set; } = "group-manifest.protected"; public string ClientStateFileName { get; set; } = "client-state.protected"; + public string PeerDirectoryFileName { get; set; } = "peer-directory.protected"; public MagicControlStartupMode StartupMode { get; set; } = MagicControlStartupMode.CachedFirst; public MagicControlRouteSelectionMode RouteSelection { get; set; } = MagicControlRouteSelectionMode.Automatic; @@ -41,6 +42,26 @@ public sealed class MagicControlClientOptions public bool EnableAutomaticDiscovery { get; set; } = true; public string DiscoveryMulticastAddress { get; set; } = MagicControlNodeProtocol.DiscoveryMulticastAddress; public int DiscoveryPort { get; set; } = MagicControlNodeProtocol.DiscoveryPort; + + /// + /// Enables application-to-application LAN discovery inside MagicControl.Client. This path + /// works without MagicControl Web, a Mesh API, or a cached authority directory. + /// + public bool EnableDirectPeerDiscovery { get; set; } = true; + + public string PeerDiscoveryMulticastAddress { get; set; } = MagicControlNodeProtocol.PeerDiscoveryMulticastAddress; + public int PeerDiscoveryPort { get; set; } = MagicControlNodeProtocol.PeerDiscoveryPort; + public TimeSpan PeerAdvertisementTtl { get; set; } = TimeSpan.FromSeconds(20); + public TimeSpan PeerDiscoveryQueryInterval { get; set; } = TimeSpan.FromSeconds(5); + public TimeSpan PeerCacheDuration { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Allows identity-verified direct peers to be returned when no usable authority manifest + /// exists. This never grants MagicControl membership or capabilities; secured authorization + /// attributes still require authority-approved cached state. + /// + public bool AllowIdentityVerifiedPeersWithoutAuthority { get; set; } = true; + public bool AllowInsecureHttp { get; set; } public List MeshEndpointSeeds { get; } = []; @@ -136,6 +157,27 @@ internal void Validate() throw new InvalidOperationException("MagicControl discovery port must be a valid UDP port."); } + if (PeerDiscoveryPort is < 1 or > 65535) + { + throw new InvalidOperationException("MagicControl peer discovery port must be a valid UDP port."); + } + + if (PeerAdvertisementTtl < TimeSpan.FromSeconds(5) + || PeerAdvertisementTtl > TimeSpan.FromMinutes(5)) + { + throw new InvalidOperationException("MagicControl peer advertisement TTL must be between five seconds and five minutes."); + } + + if (PeerDiscoveryQueryInterval < TimeSpan.FromSeconds(1)) + { + throw new InvalidOperationException("MagicControl peer discovery query interval must be at least one second."); + } + + if (PeerCacheDuration < PeerAdvertisementTtl) + { + throw new InvalidOperationException("MagicControl peer cache duration must be at least as long as the advertisement TTL."); + } + foreach (var endpoint in MeshEndpointSeeds) { ValidateEndpoint(endpoint); From fe552060923429886c881b1742db5b1306639f00 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:52:37 -0400 Subject: [PATCH 04/35] Verify signed peer advertisements --- .../MagicControlPeerAdvertisementSecurity.cs | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 MagicControl/Client/Discovery/MagicControlPeerAdvertisementSecurity.cs diff --git a/MagicControl/Client/Discovery/MagicControlPeerAdvertisementSecurity.cs b/MagicControl/Client/Discovery/MagicControlPeerAdvertisementSecurity.cs new file mode 100644 index 0000000..8e3181a --- /dev/null +++ b/MagicControl/Client/Discovery/MagicControlPeerAdvertisementSecurity.cs @@ -0,0 +1,170 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using MagicControl.Shared.Mesh; +using MagicSettings.Share; + +namespace MagicControl.Client; + +internal sealed record MagicControlPeerAdvertisementValidation( + bool IsValid, + string? Error) +{ + public static MagicControlPeerAdvertisementValidation Valid { get; } = new(true, null); + public static MagicControlPeerAdvertisementValidation Invalid(string error) => new(false, error); +} + +internal static class MagicControlPeerAdvertisementSecurity +{ + private const string ProofVersion = "MAGICSETTINGS-PROOF-V1"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never + }; + + public static byte[] Serialize(MagicControlPeerAdvertisement advertisement) + => JsonSerializer.SerializeToUtf8Bytes(advertisement, JsonOptions); + + public static string ComputeBodySha256(MagicControlPeerAdvertisement advertisement) + => Convert.ToHexString(SHA256.HashData(Serialize(advertisement))).ToLowerInvariant(); + + public static Uri Target(MagicControlPeerAdvertisement advertisement) + => new( + $"https://magiccontrol.local/groups/{advertisement.GroupId:D}/peers/{advertisement.Identity.NodeId:D}/{advertisement.Identity.CredentialId:D}/advertisement", + UriKind.Absolute); + + public static MagicControlPeerAdvertisementValidation Validate( + SignedMagicControlPeerAdvertisement envelope, + MagicControlClientOptions options, + DateTimeOffset nowUtc, + bool enforceCurrentLifetime) + { + ArgumentNullException.ThrowIfNull(envelope); + var advertisement = envelope.Advertisement; + var proof = envelope.Proof; + + if (advertisement.ProtocolVersion != MagicControlNodeProtocol.PeerDiscoveryProtocolVersion) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement protocol version is unsupported."); + } + + if (advertisement.GroupId != options.GroupId) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement belongs to another group."); + } + + if (string.IsNullOrWhiteSpace(advertisement.ApplicationName) + || string.IsNullOrWhiteSpace(advertisement.DisplayName) + || advertisement.Endpoints.Count == 0) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement is missing required application metadata or endpoints."); + } + + if (advertisement.TimeToLiveSeconds is < 5 or > 300) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement TTL is outside the allowed range."); + } + + if (advertisement.Endpoints.Any(endpoint => + endpoint.Uri is null + || !endpoint.Uri.IsAbsoluteUri + || !options.IsEndpointAllowed(endpoint.Uri))) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement contains an endpoint that is not allowed by this client."); + } + + if (proof.NodeId != advertisement.Identity.NodeId + || proof.CredentialId != advertisement.Identity.CredentialId) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer proof does not match the advertised identity."); + } + + if (!string.Equals(proof.Version, ProofVersion, StringComparison.Ordinal) + || !string.Equals(proof.Audience, MagicControlNodeProtocol.PeerDiscoveryAudience, StringComparison.Ordinal) + || !string.Equals(proof.Method, "ANNOUNCE", StringComparison.OrdinalIgnoreCase) + || !string.Equals(proof.Target, Target(advertisement).AbsoluteUri, StringComparison.Ordinal) + || !string.Equals(proof.BodySha256, ComputeBodySha256(advertisement), StringComparison.OrdinalIgnoreCase) + || proof.ExpiresUtc <= proof.IssuedUtc + || proof.ExpiresUtc - proof.IssuedUtc > TimeSpan.FromMinutes(5) + || string.IsNullOrWhiteSpace(proof.Nonce)) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement proof is structurally invalid."); + } + + if (advertisement.IssuedUtc < proof.IssuedUtc - TimeSpan.FromSeconds(5) + || advertisement.IssuedUtc > proof.ExpiresUtc) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement timestamp is not bound to the proof lifetime."); + } + + if (proof.IssuedUtc - TimeSpan.FromSeconds(30) > nowUtc) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement proof was issued too far in the future."); + } + + if (enforceCurrentLifetime + && (proof.ExpiresUtc + TimeSpan.FromSeconds(30) < nowUtc + || advertisement.IssuedUtc.AddSeconds(advertisement.TimeToLiveSeconds) < nowUtc)) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement has expired."); + } + + string expectedFingerprint; + try + { + expectedFingerprint = Convert.ToHexString( + SHA256.HashData(Convert.FromBase64String(advertisement.Identity.PublicKey))) + .ToLowerInvariant(); + } + catch (FormatException) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer public key is malformed."); + } + + if (!string.Equals( + expectedFingerprint, + advertisement.Identity.Fingerprint, + StringComparison.OrdinalIgnoreCase)) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer fingerprint does not match its public key."); + } + + try + { + using var key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo( + Convert.FromBase64String(advertisement.Identity.PublicKey), + out _); + if (!key.VerifyData( + Encoding.UTF8.GetBytes(Canonicalize(proof)), + Convert.FromBase64String(proof.Signature), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation)) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement signature is invalid."); + } + } + catch (Exception exception) when (exception is CryptographicException or FormatException) + { + return MagicControlPeerAdvertisementValidation.Invalid("The peer advertisement key or signature is malformed."); + } + + return MagicControlPeerAdvertisementValidation.Valid; + } + + private static string Canonicalize(MagicAuthenticationProof proof) + => string.Join( + '\n', + proof.Version, + proof.NodeId.ToString("D"), + proof.CredentialId.ToString("D"), + proof.Audience, + proof.Method.ToUpperInvariant(), + proof.Target, + proof.BodySha256.ToLowerInvariant(), + proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + proof.Nonce); +} From 2fa054edbc88f31ae5e9969e3ad0ba4f902ca712 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:53:07 -0400 Subject: [PATCH 05/35] Cache verified direct peers --- .../Discovery/MagicControlPeerDirectory.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 MagicControl/Client/Discovery/MagicControlPeerDirectory.cs diff --git a/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs b/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs new file mode 100644 index 0000000..6381318 --- /dev/null +++ b/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs @@ -0,0 +1,123 @@ +using System.Security.Cryptography; +using MagicControl.Shared.Mesh; + +namespace MagicControl.Client; + +public sealed record MagicControlPeerObservation( + SignedMagicControlPeerAdvertisement Envelope, + DateTimeOffset LastSeenUtc, + bool LoadedFromDisk) +{ + public MagicControlPeerAdvertisement Advertisement => Envelope.Advertisement; + + public bool IsLive(DateTimeOffset nowUtc) + => Advertisement.IssuedUtc.AddSeconds(Advertisement.TimeToLiveSeconds) >= nowUtc; +} + +public interface IMagicControlPeerDirectoryStore +{ + ValueTask> LoadAsync( + CancellationToken cancellationToken = default); + + ValueTask SaveAsync( + IReadOnlyList observations, + CancellationToken cancellationToken = default); +} + +public sealed class MagicControlPeerDirectory(MagicControlClientOptions options) +{ + private readonly object _gate = new(); + private readonly Dictionary _observations = + new(StringComparer.OrdinalIgnoreCase); + + public bool Accept( + SignedMagicControlPeerAdvertisement envelope, + DateTimeOffset receivedUtc, + bool loadedFromDisk = false) + { + ArgumentNullException.ThrowIfNull(envelope); + var advertisement = envelope.Advertisement; + var key = Key(advertisement); + + lock (_gate) + { + if (_observations.TryGetValue(key, out var existing)) + { + if (!PublicKeysMatch( + existing.Advertisement.Identity.PublicKey, + advertisement.Identity.PublicKey)) + { + return false; + } + + if (advertisement.Sequence < existing.Advertisement.Sequence + || (advertisement.Sequence == existing.Advertisement.Sequence + && advertisement.IssuedUtc <= existing.Advertisement.IssuedUtc)) + { + return false; + } + } + + _observations[key] = new MagicControlPeerObservation( + envelope, + receivedUtc, + loadedFromDisk); + return true; + } + } + + public IReadOnlyList GetActive( + DateTimeOffset? nowUtc = null) + { + var now = nowUtc ?? DateTimeOffset.UtcNow; + lock (_gate) + { + RemoveExpiredUnderLock(now); + return _observations.Values + .OrderByDescending(observation => observation.IsLive(now)) + .ThenByDescending(observation => observation.LastSeenUtc) + .ToArray(); + } + } + + public IReadOnlyList Snapshot() + { + lock (_gate) + { + return _observations.Values.ToArray(); + } + } + + private void RemoveExpiredUnderLock(DateTimeOffset nowUtc) + { + foreach (var key in _observations + .Where(pair => pair.Value.LastSeenUtc.Add(options.PeerCacheDuration) < nowUtc) + .Select(pair => pair.Key) + .ToArray()) + { + _observations.Remove(key); + } + } + + private static string Key(MagicControlPeerAdvertisement advertisement) + => string.Join( + ':', + advertisement.GroupId.ToString("D"), + advertisement.ApplicationName, + advertisement.Identity.NodeId.ToString("D"), + advertisement.Identity.CredentialId.ToString("D")); + + private static bool PublicKeysMatch(string expected, string actual) + { + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(expected), + Convert.FromBase64String(actual)); + } + catch (FormatException) + { + return false; + } + } +} From 9348a9fb89917f91df82fa1f5da8fdf86d88105d Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:53:22 -0400 Subject: [PATCH 06/35] Persist verified peer directory --- .../FileMagicControlPeerDirectoryStore.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 MagicControl/Client/State/FileMagicControlPeerDirectoryStore.cs diff --git a/MagicControl/Client/State/FileMagicControlPeerDirectoryStore.cs b/MagicControl/Client/State/FileMagicControlPeerDirectoryStore.cs new file mode 100644 index 0000000..5c32f08 --- /dev/null +++ b/MagicControl/Client/State/FileMagicControlPeerDirectoryStore.cs @@ -0,0 +1,110 @@ +using System.Security.Cryptography; + +namespace MagicControl.Client; + +public sealed class FileMagicControlPeerDirectoryStore : IMagicControlPeerDirectoryStore, IDisposable +{ + private readonly string _path; + private readonly ProtectedManifestFileCodec _codec; + private readonly SemaphoreSlim _gate = new(1, 1); + + public FileMagicControlPeerDirectoryStore(MagicControlClientOptions options) + { + _path = Path.GetFullPath(Path.Combine(options.StatePath, options.PeerDirectoryFileName)); + _codec = new ProtectedManifestFileCodec( + options.StatePath, + $"MagicControl.Client.Peers:{options.ApplicationName}:{options.GroupId:D}"); + } + + public async ValueTask> LoadAsync( + CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (!File.Exists(_path)) + { + return []; + } + + try + { + var contents = await File.ReadAllBytesAsync(_path, cancellationToken); + return _codec.Unprotect(contents); + } + catch (Exception exception) when ( + exception is IOException or CryptographicException or InvalidDataException) + { + return []; + } + } + finally + { + _gate.Release(); + } + } + + public async ValueTask SaveAsync( + IReadOnlyList observations, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(observations); + await _gate.WaitAsync(cancellationToken); + try + { + var directory = Path.GetDirectoryName(_path)!; + Directory.CreateDirectory(directory); + RestrictDirectory(directory); + + var temporary = _path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + var persistent = observations + .Select(observation => observation with { LoadedFromDisk = false }) + .ToArray(); + await File.WriteAllBytesAsync( + temporary, + _codec.Protect(persistent), + cancellationToken); + RestrictFile(temporary); + File.Move(temporary, _path, overwrite: true); + RestrictFile(_path); + } + finally + { + if (File.Exists(temporary)) + { + File.Delete(temporary); + } + } + } + finally + { + _gate.Release(); + } + } + + private static void RestrictFile(string path) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + private static void RestrictDirectory(string path) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } + + public void Dispose() + { + _gate.Dispose(); + _codec.Dispose(); + } +} From 88e1ae5a20909d5d4c276812448a596058b1c90c Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:53:55 -0400 Subject: [PATCH 07/35] Discover applications directly over LAN --- .../MagicControlPeerDiscoveryService.cs | 323 ++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs diff --git a/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs b/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs new file mode 100644 index 0000000..6b1126a --- /dev/null +++ b/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs @@ -0,0 +1,323 @@ +using System.Net; +using System.Net.Sockets; +using System.Text.Json; +using MagicControl.Shared.Mesh; +using MagicSettings; +using MagicSettings.Share; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace MagicControl.Client; + +public sealed class MagicControlPeerDiscoveryService( + MagicControlClientOptions options, + MagicControlPeerDirectory directory, + IMagicControlPeerDirectoryStore store, + IServiceProvider serviceProvider, + ILogger logger) : BackgroundService +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private long _sequence = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!options.EnableDirectPeerDiscovery) + { + return; + } + + var authenticator = serviceProvider.GetService(); + MagicNodeIdentityDescriptor? ownIdentity = null; + if (authenticator is not null) + { + ownIdentity = await authenticator.GetCurrentIdentityAsync(stoppingToken); + } + else + { + logger.LogInformation( + "MagicControl direct peer discovery will listen for applications but cannot advertise this application because no IMagicNodeAuthenticator is registered."); + } + + await LoadCachedPeersAsync(ownIdentity, stoppingToken); + + UdpClient? receiver = null; + var dirty = false; + try + { + var multicast = IPAddress.Parse(options.PeerDiscoveryMulticastAddress); + var multicastEndpoint = new IPEndPoint(multicast, options.PeerDiscoveryPort); + + receiver = new UdpClient(AddressFamily.InterNetwork) + { + ExclusiveAddressUse = false, + MulticastLoopback = true + }; + receiver.Client.SetSocketOption( + SocketOptionLevel.Socket, + SocketOptionName.ReuseAddress, + true); + receiver.Client.Bind(new IPEndPoint(IPAddress.Any, options.PeerDiscoveryPort)); + receiver.JoinMulticastGroup(multicast); + + using var sender = new UdpClient(AddressFamily.InterNetwork) + { + MulticastLoopback = true + }; + + var nextQueryUtc = DateTimeOffset.MinValue; + var nextAdvertisementUtc = DateTimeOffset.MinValue; + var nextPersistUtc = DateTimeOffset.UtcNow.AddMinutes(1); + + while (!stoppingToken.IsCancellationRequested) + { + var now = DateTimeOffset.UtcNow; + if (now >= nextQueryUtc) + { + await SendAsync( + sender, + multicastEndpoint, + new PeerDiscoveryDatagram( + MagicControlNodeProtocol.PeerDiscoveryProtocolVersion, + "query", + options.GroupId, + null, + null), + stoppingToken); + nextQueryUtc = now.Add(options.PeerDiscoveryQueryInterval); + } + + if (authenticator is not null + && options.AdvertisedEndpoints.Count > 0 + && now >= nextAdvertisementUtc) + { + var envelope = await CreateAdvertisementAsync(authenticator, stoppingToken); + ownIdentity = envelope.Advertisement.Identity; + await SendAsync( + sender, + multicastEndpoint, + new PeerDiscoveryDatagram( + MagicControlNodeProtocol.PeerDiscoveryProtocolVersion, + "advertisement", + options.GroupId, + options.ApplicationName, + envelope), + stoppingToken); + nextAdvertisementUtc = now.Add(TimeSpan.FromTicks( + Math.Max( + TimeSpan.FromSeconds(2).Ticks, + options.PeerAdvertisementTtl.Ticks / 2))); + } + + if (dirty && now >= nextPersistUtc) + { + await store.SaveAsync(directory.Snapshot(), stoppingToken); + dirty = false; + nextPersistUtc = now.AddMinutes(1); + } + + using var receiveTimeout = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); + receiveTimeout.CancelAfter(TimeSpan.FromMilliseconds(500)); + try + { + var received = await receiver.ReceiveAsync(receiveTimeout.Token); + var datagram = Deserialize(received.Buffer); + if (datagram?.Version != MagicControlNodeProtocol.PeerDiscoveryProtocolVersion + || datagram.GroupId != options.GroupId) + { + continue; + } + + if (string.Equals(datagram.Kind, "query", StringComparison.OrdinalIgnoreCase)) + { + if (authenticator is null + || options.AdvertisedEndpoints.Count == 0 + || (!string.IsNullOrWhiteSpace(datagram.ApplicationName) + && !string.Equals( + datagram.ApplicationName, + options.ApplicationName, + StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + var envelope = await CreateAdvertisementAsync(authenticator, stoppingToken); + ownIdentity = envelope.Advertisement.Identity; + await SendAsync( + sender, + multicastEndpoint, + new PeerDiscoveryDatagram( + MagicControlNodeProtocol.PeerDiscoveryProtocolVersion, + "advertisement", + options.GroupId, + options.ApplicationName, + envelope), + stoppingToken); + continue; + } + + if (!string.Equals(datagram.Kind, "advertisement", StringComparison.OrdinalIgnoreCase) + || datagram.Advertisement is null) + { + continue; + } + + var advertisement = datagram.Advertisement.Advertisement; + if (ownIdentity is not null + && advertisement.Identity.NodeId == ownIdentity.NodeId + && advertisement.Identity.CredentialId == ownIdentity.CredentialId) + { + continue; + } + + var validation = MagicControlPeerAdvertisementSecurity.Validate( + datagram.Advertisement, + options, + DateTimeOffset.UtcNow, + enforceCurrentLifetime: true); + if (!validation.IsValid) + { + logger.LogDebug( + "Ignored an invalid MagicControl peer advertisement: {Reason}", + validation.Error); + continue; + } + + if (directory.Accept( + datagram.Advertisement, + DateTimeOffset.UtcNow)) + { + dirty = true; + } + } + catch (OperationCanceledException) when (!stoppingToken.IsCancellationRequested) + { + // Periodic wake-up for advertisements, queries, and persistence. + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (Exception exception) when ( + exception is SocketException or FormatException or InvalidOperationException) + { + logger.LogWarning( + exception, + "MagicControl direct peer discovery is unavailable. Signed directories and explicit routes remain usable."); + } + finally + { + receiver?.Dispose(); + if (dirty) + { + try + { + await store.SaveAsync(directory.Snapshot(), CancellationToken.None); + } + catch (Exception exception) when (exception is IOException or CryptographicException) + { + logger.LogWarning(exception, "MagicControl could not persist the direct peer cache."); + } + } + } + } + + private async ValueTask LoadCachedPeersAsync( + MagicNodeIdentityDescriptor? ownIdentity, + CancellationToken cancellationToken) + { + var now = DateTimeOffset.UtcNow; + foreach (var observation in await store.LoadAsync(cancellationToken)) + { + var advertisement = observation.Advertisement; + if (observation.LastSeenUtc.Add(options.PeerCacheDuration) < now + || (ownIdentity is not null + && advertisement.Identity.NodeId == ownIdentity.NodeId + && advertisement.Identity.CredentialId == ownIdentity.CredentialId)) + { + continue; + } + + var validation = MagicControlPeerAdvertisementSecurity.Validate( + observation.Envelope, + options, + now, + enforceCurrentLifetime: false); + if (validation.IsValid) + { + directory.Accept( + observation.Envelope, + observation.LastSeenUtc, + loadedFromDisk: true); + } + } + } + + private async ValueTask CreateAdvertisementAsync( + IMagicNodeAuthenticator authenticator, + CancellationToken cancellationToken) + { + var identity = await authenticator.GetCurrentIdentityAsync(cancellationToken); + var issuedUtc = DateTimeOffset.UtcNow; + var ttlSeconds = checked((int)Math.Ceiling(options.PeerAdvertisementTtl.TotalSeconds)); + var advertisement = new MagicControlPeerAdvertisement( + MagicControlNodeProtocol.PeerDiscoveryProtocolVersion, + options.GroupId, + options.ApplicationName, + options.DisplayName!, + options.InstanceName, + options.InstanceRole, + options.SiteName, + options.Version, + identity, + options.AdvertisedEndpoints.ToArray(), + Interlocked.Increment(ref _sequence), + issuedUtc, + ttlSeconds); + var proof = await authenticator.CreateProofAsync( + new MagicAuthenticationRequest( + MagicControlNodeProtocol.PeerDiscoveryAudience, + "ANNOUNCE", + MagicControlPeerAdvertisementSecurity.Target(advertisement), + MagicControlPeerAdvertisementSecurity.ComputeBodySha256(advertisement), + TimeSpan.FromSeconds(Math.Min(60, ttlSeconds + 30))), + cancellationToken); + return new SignedMagicControlPeerAdvertisement(advertisement, proof); + } + + private static async ValueTask SendAsync( + UdpClient client, + IPEndPoint endpoint, + PeerDiscoveryDatagram datagram, + CancellationToken cancellationToken) + { + var payload = JsonSerializer.SerializeToUtf8Bytes(datagram, JsonOptions); + if (payload.Length > 60_000) + { + throw new InvalidOperationException( + "The MagicControl peer advertisement is too large for UDP discovery."); + } + + await client.SendAsync(payload, endpoint, cancellationToken); + } + + private static PeerDiscoveryDatagram? Deserialize(byte[] payload) + { + try + { + return JsonSerializer.Deserialize(payload, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + private sealed record PeerDiscoveryDatagram( + int Version, + string Kind, + Guid GroupId, + string? ApplicationName, + SignedMagicControlPeerAdvertisement? Advertisement); +} From e454031def49f8f0458e016d87b0b816d6ffda9a Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:54:33 -0400 Subject: [PATCH 08/35] Resolve direct peers without Mesh --- .../Discovery/MagicControlServiceResolver.cs | 248 +++++++++++++++--- 1 file changed, 214 insertions(+), 34 deletions(-) diff --git a/MagicControl/Client/Discovery/MagicControlServiceResolver.cs b/MagicControl/Client/Discovery/MagicControlServiceResolver.cs index 9a86e30..86a632d 100644 --- a/MagicControl/Client/Discovery/MagicControlServiceResolver.cs +++ b/MagicControl/Client/Discovery/MagicControlServiceResolver.cs @@ -1,10 +1,37 @@ +using System.Security.Cryptography; +using System.Text; using MagicControl.Shared.Mesh; +using MagicSettings.Share; namespace MagicControl.Client; +public enum MagicControlPeerTrustLevel +{ + IdentityVerified = 1, + AuthorityDirectory = 2, + AuthorityApproved = 3 +} + +public enum MagicControlServiceDiscoverySource +{ + DirectPeerCache = 1, + DirectPeerLan = 2, + SignedDirectory = 3 +} + public sealed record MagicControlResolvedService( MagicControlDirectoryEntry Instance, - MagicControlServiceEndpoint Endpoint); + MagicControlServiceEndpoint Endpoint) +{ + public MagicControlPeerTrustLevel TrustLevel { get; init; } = + MagicControlPeerTrustLevel.AuthorityDirectory; + + public MagicControlServiceDiscoverySource Source { get; init; } = + MagicControlServiceDiscoverySource.SignedDirectory; + + public bool IsAuthorityApproved => + TrustLevel == MagicControlPeerTrustLevel.AuthorityApproved; +} public interface IMagicControlServiceResolver { @@ -20,54 +47,70 @@ IReadOnlyList ResolveAll( void ReportFailure(Uri endpoint, TimeSpan? retryAfter = null); } -public sealed class MagicControlServiceResolver( - MagicControlClientOptions options, - MagicControlManifestCache cache) : IMagicControlServiceResolver +public sealed class MagicControlServiceResolver : IMagicControlServiceResolver { + private readonly MagicControlClientOptions _options; + private readonly MagicControlManifestCache _cache; + private readonly MagicControlPeerDirectory _peers; private readonly object _gate = new(); private readonly Dictionary _roundRobin = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _unhealthyUntil = new(StringComparer.OrdinalIgnoreCase); + public MagicControlServiceResolver( + MagicControlClientOptions options, + MagicControlManifestCache cache) + : this(options, cache, new MagicControlPeerDirectory(options)) + { + } + + public MagicControlServiceResolver( + MagicControlClientOptions options, + MagicControlManifestCache cache, + MagicControlPeerDirectory peers) + { + _options = options; + _cache = cache; + _peers = peers; + } + public IReadOnlyList ResolveAll( string applicationName, DateTimeOffset? nowUtc = null) { ArgumentException.ThrowIfNullOrWhiteSpace(applicationName); var now = nowUtc ?? DateTimeOffset.UtcNow; - var state = cache.Get(options.GroupId); - if (state is null || !state.AllowsOfflineUse(now)) + var state = _cache.Get(_options.GroupId); + var usableManifest = state is not null && state.AllowsOfflineUse(now) + ? state.Manifest + : null; + + var all = new List(); + if (usableManifest is not null) { - return []; + all.AddRange(FromSignedDirectory(usableManifest, applicationName, now)); } - var resolved = state.Manifest.Directory - .Where(instance => string.Equals( - instance.ApplicationName, - applicationName, - StringComparison.OrdinalIgnoreCase)) - .Where(instance => instance.ExpiresUtc is null || instance.ExpiresUtc >= now) - .SelectMany(instance => instance.Endpoints.Select(endpoint => - new MagicControlResolvedService(instance, endpoint))) - .Where(candidate => IsHealthy(candidate.Endpoint.Uri, now)) - .OrderBy(candidate => RouteScore(candidate.Endpoint)) - .ThenBy(candidate => candidate.Endpoint.Priority) - .ThenBy(candidate => candidate.Instance.ManagedInstanceId) - .ThenBy(candidate => candidate.Endpoint.Uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase) + if (usableManifest is not null || _options.AllowIdentityVerifiedPeersWithoutAuthority) + { + all.AddRange(FromDirectPeers(usableManifest, applicationName, now)); + } + + var deduplicated = all + .GroupBy( + candidate => $"{candidate.Instance.ManagedInstanceId:D}|{candidate.Endpoint.Uri.AbsoluteUri.TrimEnd('/')}", + StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(candidate => candidate.TrustLevel) + .ThenBy(candidate => SourceScore(candidate.Source)) + .First()) .ToArray(); - return resolved.Length > 0 - ? resolved - : state.Manifest.Directory - .Where(instance => string.Equals( - instance.ApplicationName, - applicationName, - StringComparison.OrdinalIgnoreCase)) - .Where(instance => instance.ExpiresUtc is null || instance.ExpiresUtc >= now) - .SelectMany(instance => instance.Endpoints.Select(endpoint => - new MagicControlResolvedService(instance, endpoint))) - .OrderBy(candidate => RouteScore(candidate.Endpoint)) - .ThenBy(candidate => candidate.Endpoint.Priority) - .ToArray(); + var healthy = Order( + deduplicated.Where(candidate => IsHealthy(candidate.Endpoint.Uri, now))) + .ToArray(); + return healthy.Length > 0 + ? healthy + : Order(deduplicated).ToArray(); } public MagicControlResolvedService? Resolve( @@ -80,7 +123,7 @@ public IReadOnlyList ResolveAll( return null; } - if (options.RouteSelection != MagicControlRouteSelectionMode.RoundRobin) + if (_options.RouteSelection != MagicControlRouteSelectionMode.RoundRobin) { return candidates[0]; } @@ -113,6 +156,108 @@ public void ReportFailure(Uri endpoint, TimeSpan? retryAfter = null) } } + private IEnumerable FromSignedDirectory( + MagicControlGroupManifest manifest, + string applicationName, + DateTimeOffset nowUtc) + => manifest.Directory + .Where(instance => string.Equals( + instance.ApplicationName, + applicationName, + StringComparison.OrdinalIgnoreCase)) + .Where(instance => instance.ExpiresUtc is null || instance.ExpiresUtc >= nowUtc) + .SelectMany(instance => instance.Endpoints.Select(endpoint => + new MagicControlResolvedService(instance, endpoint) + { + TrustLevel = manifest.SecurityMode == MagicControlGroupSecurityMode.Secured + ? MagicControlPeerTrustLevel.AuthorityApproved + : MagicControlPeerTrustLevel.AuthorityDirectory, + Source = MagicControlServiceDiscoverySource.SignedDirectory + })); + + private IEnumerable FromDirectPeers( + MagicControlGroupManifest? manifest, + string applicationName, + DateTimeOffset nowUtc) + { + foreach (var observation in _peers.GetActive(nowUtc)) + { + var advertisement = observation.Advertisement; + if (!string.Equals( + advertisement.ApplicationName, + applicationName, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + MagicControlMember? member = null; + if (manifest is not null) + { + member = manifest.Members.SingleOrDefault(candidate => + candidate.NodeId == advertisement.Identity.NodeId + && candidate.CredentialId == advertisement.Identity.CredentialId + && candidate.CredentialStatus is MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring + && string.Equals( + candidate.ApplicationName, + advertisement.ApplicationName, + StringComparison.OrdinalIgnoreCase) + && PublicKeysMatch( + candidate.PublicKey, + advertisement.Identity.PublicKey)); + + if (manifest.SecurityMode == MagicControlGroupSecurityMode.Secured + && member is null) + { + continue; + } + } + + var trust = member is not null + ? MagicControlPeerTrustLevel.AuthorityApproved + : MagicControlPeerTrustLevel.IdentityVerified; + var instance = new MagicControlDirectoryEntry( + member?.ManagedInstanceId ?? DerivePeerId(advertisement), + advertisement.ApplicationName, + advertisement.InstanceName, + advertisement.InstanceRole, + advertisement.SiteName, + advertisement.Endpoints + .Select(endpoint => new MagicControlServiceEndpoint( + endpoint.Uri, + endpoint.Priority, + endpoint.IsLoopback || endpoint.Uri.IsLoopback, + endpoint.IsLan, + endpoint.Transport)) + .ToArray(), + advertisement.Sequence, + observation.LastSeenUtc, + observation.LastSeenUtc.Add(_options.PeerCacheDuration)); + var source = observation.LoadedFromDisk || !observation.IsLive(nowUtc) + ? MagicControlServiceDiscoverySource.DirectPeerCache + : MagicControlServiceDiscoverySource.DirectPeerLan; + + foreach (var endpoint in instance.Endpoints) + { + yield return new MagicControlResolvedService(instance, endpoint) + { + TrustLevel = trust, + Source = source + }; + } + } + } + + private IOrderedEnumerable Order( + IEnumerable candidates) + => candidates + .OrderBy(candidate => RouteScore(candidate.Endpoint)) + .ThenBy(candidate => candidate.Endpoint.Priority) + .ThenByDescending(candidate => candidate.TrustLevel) + .ThenBy(candidate => SourceScore(candidate.Source)) + .ThenBy(candidate => candidate.Instance.ManagedInstanceId) + .ThenBy(candidate => candidate.Endpoint.Uri.AbsoluteUri, StringComparer.OrdinalIgnoreCase); + private bool IsHealthy(Uri endpoint, DateTimeOffset now) { lock (_gate) @@ -139,6 +284,15 @@ private static int RouteScore(MagicControlServiceEndpoint endpoint) return 30; } + private static int SourceScore(MagicControlServiceDiscoverySource source) + => source switch + { + MagicControlServiceDiscoverySource.DirectPeerLan => 0, + MagicControlServiceDiscoverySource.SignedDirectory => 10, + MagicControlServiceDiscoverySource.DirectPeerCache => 20, + _ => 30 + }; + private static bool IsPrivateAddress(string host) { if (!System.Net.IPAddress.TryParse(host, out var address) @@ -152,4 +306,30 @@ private static bool IsPrivateAddress(string host) || (bytes[0] == 172 && bytes[1] is >= 16 and <= 31) || (bytes[0] == 192 && bytes[1] == 168); } + + private static Guid DerivePeerId(MagicControlPeerAdvertisement advertisement) + { + var canonical = string.Join( + ':', + advertisement.GroupId.ToString("D"), + advertisement.ApplicationName, + advertisement.Identity.NodeId.ToString("D"), + advertisement.Identity.CredentialId.ToString("D")); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(canonical)); + return new Guid(hash.AsSpan(0, 16)); + } + + private static bool PublicKeysMatch(string expected, string actual) + { + try + { + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(expected), + Convert.FromBase64String(actual)); + } + catch (FormatException) + { + return false; + } + } } From ef88888fab403571c88971e1857da04d29f6af60 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:54:53 -0400 Subject: [PATCH 09/35] Register direct peer discovery services --- .../Configuration/MagicControlClientExtensions.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/MagicControl/Client/Configuration/MagicControlClientExtensions.cs b/MagicControl/Client/Configuration/MagicControlClientExtensions.cs index 9bba1b9..03bd78e 100644 --- a/MagicControl/Client/Configuration/MagicControlClientExtensions.cs +++ b/MagicControl/Client/Configuration/MagicControlClientExtensions.cs @@ -32,6 +32,8 @@ public static async ValueTask AddMagicControl var cache = new MagicControlManifestCache(); var manifestStore = new FileMagicControlManifestStore(clientOptions); var clientStateStore = new FileMagicControlClientStateStore(clientOptions); + var peerDirectory = new MagicControlPeerDirectory(clientOptions); + var peerDirectoryStore = new FileMagicControlPeerDirectoryStore(clientOptions); var persistentState = await clientStateStore.LoadAsync(cancellationToken); clientOptions.TrustedAuthorityPublicKey ??= persistentState.AuthorityPublicKey; var contextHash = clientOptions.ComputeContextHash(persistentState.BootstrapNonce); @@ -86,6 +88,8 @@ public static async ValueTask AddMagicControl cache, manifestStore, clientStateStore, + peerDirectory, + peerDirectoryStore, validator, endpointResolver, status, @@ -107,6 +111,8 @@ public static IServiceCollection AddMagicControlClient( var cache = new MagicControlManifestCache(); var manifestStore = new FileMagicControlManifestStore(options); var stateStore = new FileMagicControlClientStateStore(options); + var peerDirectory = new MagicControlPeerDirectory(options); + var peerDirectoryStore = new FileMagicControlPeerDirectoryStore(options); var validator = new MagicControlManifestValidator(options); var status = new MagicControlClientStatus(); var resolver = new DiscoveringMagicControlMeshEndpointResolver(options, stateStore); @@ -116,6 +122,8 @@ public static IServiceCollection AddMagicControlClient( services.AddSingleton(cache); services.AddSingleton(manifestStore); services.AddSingleton(stateStore); + services.AddSingleton(peerDirectory); + services.AddSingleton(peerDirectoryStore); services.AddSingleton(validator); services.AddSingleton(resolver); services.AddSingleton(status); @@ -124,6 +132,7 @@ public static IServiceCollection AddMagicControlClient( services.AddHttpClient(MagicControlHttpClients.Mesh) .AddMagicNodeAuthentication(MagicControlMeshProtocol.MeshPeerAudience); services.AddHostedService(); + services.AddHostedService(); return services; } @@ -161,6 +170,8 @@ private static void RegisterClientServices( MagicControlManifestCache cache, FileMagicControlManifestStore manifestStore, FileMagicControlClientStateStore stateStore, + MagicControlPeerDirectory peerDirectory, + FileMagicControlPeerDirectoryStore peerDirectoryStore, MagicControlManifestValidator validator, DiscoveringMagicControlMeshEndpointResolver endpointResolver, MagicControlClientStatus status, @@ -171,6 +182,8 @@ private static void RegisterClientServices( services.AddSingleton(cache); services.AddSingleton(manifestStore); services.AddSingleton(stateStore); + services.AddSingleton(peerDirectory); + services.AddSingleton(peerDirectoryStore); services.AddSingleton(validator); services.AddSingleton(endpointResolver); services.AddSingleton(status); @@ -181,5 +194,6 @@ private static void RegisterClientServices( services.AddHttpClient(MagicControlHttpClients.Mesh) .AddMagicNodeAuthentication(MagicControlMeshProtocol.MeshPeerAudience); services.AddHostedService(); + services.AddHostedService(); } } From 143467c1909c15c400836b999cdb6ef6e69a237c Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:55:44 -0400 Subject: [PATCH 10/35] Test direct peer discovery trust boundaries --- .../MagicControl.Tests/PeerDiscoveryTests.cs | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs diff --git a/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs new file mode 100644 index 0000000..e06f70c --- /dev/null +++ b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs @@ -0,0 +1,267 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using MagicControl.Client; +using MagicControl.Shared.Enrollments; +using MagicControl.Shared.Mesh; +using MagicSettings.Share; + +namespace MagicControl.Tests; + +public sealed class PeerDiscoveryTests +{ + [Fact] + public void PeerAdvertisement_ValidatesAndRejectsTampering() + { + var options = CreateOptions(); + var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); + + var valid = MagicControlPeerAdvertisementSecurity.Validate( + signed, + options, + DateTimeOffset.UtcNow, + enforceCurrentLifetime: true); + var tampered = signed with + { + Advertisement = signed.Advertisement with + { + ApplicationName = "Inventory" + } + }; + var invalid = MagicControlPeerAdvertisementSecurity.Validate( + tampered, + options, + DateTimeOffset.UtcNow, + enforceCurrentLifetime: true); + + Assert.True(valid.IsValid, valid.Error); + Assert.False(invalid.IsValid); + } + + [Fact] + public void ServiceResolver_ReturnsIdentityVerifiedPeerWithoutManifest() + { + var options = CreateOptions(); + var cache = new MagicControlManifestCache(); + var peers = new MagicControlPeerDirectory(options); + var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); + Assert.True(peers.Accept(signed, DateTimeOffset.UtcNow)); + var resolver = new MagicControlServiceResolver(options, cache, peers); + + var result = resolver.Resolve("Orders"); + + Assert.NotNull(result); + Assert.Equal(MagicControlPeerTrustLevel.IdentityVerified, result.TrustLevel); + Assert.Equal(MagicControlServiceDiscoverySource.DirectPeerLan, result.Source); + Assert.Equal("192.168.10.25", result.Endpoint.Uri.Host); + Assert.False(result.IsAuthorityApproved); + } + + [Fact] + public void ServiceResolver_SecuredManifestRejectsUnapprovedDirectPeer() + { + var options = CreateOptions(); + var cache = CreateManifestCache(options.GroupId, []); + var peers = new MagicControlPeerDirectory(options); + var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); + Assert.True(peers.Accept(signed, DateTimeOffset.UtcNow)); + var resolver = new MagicControlServiceResolver(options, cache, peers); + + var result = resolver.Resolve("Orders"); + + Assert.Null(result); + } + + [Fact] + public void ServiceResolver_SecuredManifestUpgradesExactApprovedPeer() + { + var options = CreateOptions(); + var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); + var advertisement = signed.Advertisement; + var member = new MagicControlMember( + Guid.NewGuid(), + EnrollmentKind.ApplicationInstance, + advertisement.DisplayName, + advertisement.ApplicationName, + advertisement.InstanceName, + advertisement.InstanceRole, + advertisement.SiteName, + advertisement.Identity.NodeId, + advertisement.Identity.CredentialId, + advertisement.Identity.PublicKey, + MagicCredentialStatus.Approved, + [], + ["orders.read"]); + var cache = CreateManifestCache(options.GroupId, [member]); + var peers = new MagicControlPeerDirectory(options); + Assert.True(peers.Accept(signed, DateTimeOffset.UtcNow)); + var resolver = new MagicControlServiceResolver(options, cache, peers); + + var result = resolver.Resolve("Orders"); + + Assert.NotNull(result); + Assert.Equal(MagicControlPeerTrustLevel.AuthorityApproved, result.TrustLevel); + Assert.Equal(MagicControlServiceDiscoverySource.DirectPeerLan, result.Source); + Assert.True(result.IsAuthorityApproved); + Assert.Equal(member.ManagedInstanceId, result.Instance.ManagedInstanceId); + } + + [Fact] + public async Task PeerDirectoryStore_EncryptsAndReloadsVerifiedObservations() + { + var statePath = Path.Combine( + Path.GetTempPath(), + "magic-control-peer-cache", + Guid.NewGuid().ToString("N")); + var options = CreateOptions() withStatePath(statePath); + var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); + var observation = new MagicControlPeerObservation( + signed, + DateTimeOffset.UtcNow, + LoadedFromDisk: false); + + try + { + using (var store = new FileMagicControlPeerDirectoryStore(options)) + { + await store.SaveAsync([observation]); + } + + var path = Path.Combine(statePath, options.PeerDirectoryFileName); + var bytes = await File.ReadAllBytesAsync(path); + Assert.DoesNotContain( + Encoding.UTF8.GetBytes("192.168.10.25"), + bytes); + + using var reopened = new FileMagicControlPeerDirectoryStore(options); + var loaded = await reopened.LoadAsync(); + Assert.Single(loaded); + var validation = MagicControlPeerAdvertisementSecurity.Validate( + loaded[0].Envelope, + options, + DateTimeOffset.UtcNow, + enforceCurrentLifetime: false); + Assert.True(validation.IsValid, validation.Error); + } + finally + { + if (Directory.Exists(statePath)) + { + Directory.Delete(statePath, recursive: true); + } + } + } + + private static MagicControlClientOptions CreateOptions() + { + var options = new MagicControlClientOptions + { + GroupId = Guid.NewGuid(), + ApplicationName = "Consumer", + EnableAutomaticDiscovery = false, + EnableDirectPeerDiscovery = false + }; + options.Validate(); + return options; + } + + private static MagicControlClientOptions withStatePath( + this MagicControlClientOptions options, + string statePath) + { + options.StatePath = statePath; + return options; + } + + private static MagicControlManifestCache CreateManifestCache( + Guid groupId, + IReadOnlyList members) + { + var now = DateTimeOffset.UtcNow; + var manifest = new MagicControlGroupManifest( + groupId, + "Home", + MagicControlGroupSecurityMode.Secured, + Guid.NewGuid(), + 1, + now, + MagicControlOfflineTrustPolicy.Infinite, + members, + [], + MagicControlSettingsSnapshot.Empty(now)); + using var authority = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var cache = new MagicControlManifestCache(); + cache.Set(new MagicControlManifestState( + MagicControlManifestCryptography.Sign(manifest, authority), + now, + LoadedFromDisk: false)); + return cache; + } + + private static SignedMagicControlPeerAdvertisement CreateSignedAdvertisement( + Guid groupId, + string applicationName) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var publicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()); + var now = DateTimeOffset.UtcNow; + var identity = new MagicNodeIdentityDescriptor( + Guid.NewGuid(), + Guid.NewGuid(), + MagicCredentialKind.EcdsaP256, + "ECDSA_P256_SHA256_P1363", + publicKey, + Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(publicKey))).ToLowerInvariant(), + now); + var advertisement = new MagicControlPeerAdvertisement( + MagicControlNodeProtocol.PeerDiscoveryProtocolVersion, + groupId, + applicationName, + $"{applicationName} primary", + "orders-1", + "api", + "home", + "1.0.0", + identity, + [new MagicControlServiceEndpointAnnouncement( + new Uri("https://192.168.10.25:7443"), + Priority: 10, + IsLan: true)], + 1, + now, + 20); + var unsigned = new MagicAuthenticationProof( + "MAGICSETTINGS-PROOF-V1", + identity.NodeId, + identity.CredentialId, + MagicControlNodeProtocol.PeerDiscoveryAudience, + "ANNOUNCE", + MagicControlPeerAdvertisementSecurity.Target(advertisement).AbsoluteUri, + MagicControlPeerAdvertisementSecurity.ComputeBodySha256(advertisement), + now, + now.AddMinutes(1), + "test-nonce", + string.Empty); + var signature = key.SignData( + Encoding.UTF8.GetBytes(Canonicalize(unsigned)), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return new SignedMagicControlPeerAdvertisement( + advertisement, + unsigned with { Signature = Convert.ToBase64String(signature) }); + } + + private static string Canonicalize(MagicAuthenticationProof proof) + => string.Join( + '\n', + proof.Version, + proof.NodeId.ToString("D"), + proof.CredentialId.ToString("D"), + proof.Audience, + proof.Method.ToUpperInvariant(), + proof.Target, + proof.BodySha256.ToLowerInvariant(), + proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + proof.Nonce); +} From 234a5fde8ac82a077c5d174b88f8e535d37925e0 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:56:16 -0400 Subject: [PATCH 11/35] Fix peer discovery test setup --- .../Tests/MagicControl.Tests/PeerDiscoveryTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs index e06f70c..0b43f0b 100644 --- a/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs +++ b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs @@ -113,7 +113,7 @@ public async Task PeerDirectoryStore_EncryptsAndReloadsVerifiedObservations() Path.GetTempPath(), "magic-control-peer-cache", Guid.NewGuid().ToString("N")); - var options = CreateOptions() withStatePath(statePath); + var options = CreateOptions().WithStatePath(statePath); var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); var observation = new MagicControlPeerObservation( signed, @@ -130,8 +130,9 @@ public async Task PeerDirectoryStore_EncryptsAndReloadsVerifiedObservations() var path = Path.Combine(statePath, options.PeerDirectoryFileName); var bytes = await File.ReadAllBytesAsync(path); Assert.DoesNotContain( - Encoding.UTF8.GetBytes("192.168.10.25"), - bytes); + "192.168.10.25", + Encoding.UTF8.GetString(bytes), + StringComparison.Ordinal); using var reopened = new FileMagicControlPeerDirectoryStore(options); var loaded = await reopened.LoadAsync(); @@ -165,7 +166,7 @@ private static MagicControlClientOptions CreateOptions() return options; } - private static MagicControlClientOptions withStatePath( + private static MagicControlClientOptions WithStatePath( this MagicControlClientOptions options, string statePath) { From ac7b29a68894a50959f21a648d91b6ece9cb6663 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:57:04 -0400 Subject: [PATCH 12/35] Document standalone peer discovery --- docs/client-platform.md | 59 ++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/docs/client-platform.md b/docs/client-platform.md index ad5b2e8..254500c 100644 --- a/docs/client-platform.md +++ b/docs/client-platform.md @@ -49,12 +49,13 @@ if (magic.ShouldExit) - MagicSettings generates and maintains the local settings document normally. - Local environment variables and custom providers continue working. -- The client performs opportunistic Mesh discovery without making platform availability a startup requirement. +- Applications advertise and discover one another directly on the LAN when endpoints are configured. +- The client also performs opportunistic Mesh discovery without making platform availability a startup requirement. - When no approved cached state and no Mesh are available, the application reports local-only status and continues normally. Use `RequireApprovedState` only for applications that must refuse startup without previously approved MagicControl state. -## Automatic discovery and endpoint overrides +## Automatic Mesh discovery and endpoint overrides On ordinary IPv4 LANs, Mesh APIs advertise themselves through the built-in multicast discovery protocol. Clients combine: @@ -70,6 +71,41 @@ client.AddMeshEndpointOverride("https://control.example.com"); It supplements discovery rather than disabling it. +## Direct application discovery without Mesh + +`MagicControl.Client` also runs a separate application-to-application discovery channel. This channel does not require Web, Mesh, or an existing signed directory. + +Each application periodically advertises: + +- its configured `GroupId` and `ApplicationName`; +- its MagicSettings node and credential identity; +- instance, role, site, and version metadata; +- configured service endpoints and priorities; +- a sequence, issue time, and short TTL; +- a MagicSettings proof over the exact advertisement body and logical peer-discovery target. + +A receiver verifies the public-key fingerprint, body hash, proof audience, method, target, lifetime, identity binding, and ECDSA signature before accepting the peer. Accepted observations are kept in memory and in a separate encrypted short-lived cache. + +With no usable authority manifest, `IMagicControlServiceResolver` may return these routes with: + +```csharp +result.TrustLevel == MagicControlPeerTrustLevel.IdentityVerified +result.Source == MagicControlServiceDiscoverySource.DirectPeerLan +``` + +Identity-verified means the advertisement was signed by the advertised persistent MagicSettings identity. It does **not** mean MagicControl Web approved that identity, and it grants no membership, role, or capability. + +When a valid secured manifest exists, direct advertisements are returned only if the manifest contains the exact node ID, credential ID, public key, application name, and approved or retiring credential. Those routes are marked `AuthorityApproved`. Unapproved direct peers are filtered out. + +Direct discovery can be disabled or tightened: + +```csharp +client.EnableDirectPeerDiscovery = false; +client.AllowIdentityVerifiedPeersWithoutAuthority = false; +``` + +The peer multicast address, port, advertisement TTL, query interval, and encrypted cache duration are configurable for environments with unusual networking requirements. + ## Secured enrollment For a secured group, first startup works without a manually pasted authority key: @@ -138,31 +174,36 @@ public IActionResult Status() => Ok(); public IActionResult CreateOrder() => Ok(); ``` -`IMagicControlAuthorizationService` remains available for manual checks and complete directory lookup. +`IMagicControlAuthorizationService` remains available for manual checks and complete directory lookup. Direct peer discovery never causes these authority-backed checks to downgrade to identity-only authorization. ## Service discovery and routing -Applications announce their reachable endpoints during node synchronization. The signed directory includes endpoint priority, loopback/LAN classification, sequence, observation time, and expiration. +Applications announce their reachable endpoints through direct peer discovery and, when connected, during node synchronization. The signed directory includes endpoint priority, loopback/LAN classification, sequence, observation time, and expiration. -`IMagicControlServiceResolver` provides normal route selection: +`IMagicControlServiceResolver` combines signed directory entries with direct peer observations: ```csharp var target = resolver.Resolve("Inventory"); if (target is null) { - // No trusted live instance is currently known. + // No usable instance is currently known. +} +else if (!target.IsAuthorityApproved) +{ + // Standalone identity-verified peer; choose whether this operation permits it. } ``` -Route preference is loopback, LAN, private routed address, then public address. Applications may report failures so an endpoint is temporarily quarantined and another trusted instance can be selected. Round-robin selection is optional. `ResolveAll` remains available when the caller needs complete policy control. +Route preference is loopback, LAN, private routed address, then public address. Applications may report failures so an endpoint is temporarily quarantined and another instance can be selected. Round-robin selection is optional. `ResolveAll` remains available when the caller needs complete policy control. ## Outage behavior - Web owns approval, settings publication, revocation, and signatures. - Mesh caches signed manifests and approved offline-safe node snapshots. -- Clients keep encrypted last-known-good authorization and permitted settings. +- Clients keep encrypted last-known-good authorization, permitted settings, and a separate short-lived direct peer cache. - Existing approved applications continue communicating directly when Web is unavailable. - Existing approved applications can recover cached state through Mesh when Web is unavailable. +- Applications with no platform can still discover identity-verified peers directly. - New enrollment and live-only secret retrieval require Web. - A finite group offline lease expires from the authority-signed manifest issue time; reaching a stale Mesh cannot extend it. - Infinite offline trust remains the default for availability-first installations. @@ -171,7 +212,7 @@ Route preference is loopback, LAN, private routed address, then public address. - **MagicControl Web** — administrative control pane and durable authority. - **MagicControl Mesh** — LAN discovery, Web relay, signed-state distribution, and outage cache. -- **MagicControl.Client** — application SDK for MagicSettings integration, enrollment, cached authorization, discovery, routing, and endpoint announcements. +- **MagicControl.Client** — application SDK for MagicSettings integration, enrollment, cached authorization, direct peer discovery, routing, and endpoint announcements. Web supports SQLite by default and optional PostgreSQL. It includes first-run administrator setup, protected primary-administrator semantics, users and roles, enrollment review, groups, managed instances, application settings, audit records, and health checks. From fec87a08c1644ac4e5c781c4d006f4257aa4c087 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:57:48 -0400 Subject: [PATCH 13/35] Harden peer discovery runtime --- .../Client/Discovery/MagicControlPeerDiscoveryService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs b/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs index 6b1126a..eb45ae5 100644 --- a/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs +++ b/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Sockets; +using System.Security.Cryptography; using System.Text.Json; using MagicControl.Shared.Mesh; using MagicSettings; @@ -281,7 +282,7 @@ private async ValueTask CreateAdvertisement "ANNOUNCE", MagicControlPeerAdvertisementSecurity.Target(advertisement), MagicControlPeerAdvertisementSecurity.ComputeBodySha256(advertisement), - TimeSpan.FromSeconds(Math.Min(60, ttlSeconds + 30))), + TimeSpan.FromSeconds(Math.Min(300, ttlSeconds + 30))), cancellationToken); return new SignedMagicControlPeerAdvertisement(advertisement, proof); } From 77fbf8b434e82781b2b6a820a7f9e34d121f9bad Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:58:06 -0400 Subject: [PATCH 14/35] Document automatic application discovery --- README.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e13b259..449ad72 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MagicControl is a lightweight application control plane for managing users, appl ## MagicControl.Client -`MagicControl.Client` is the application-side NuGet package. It initializes MagicSettings, maintains the application's MagicControl identity, refreshes signed group manifests in the background, authorizes peer requests from local cached state, and resolves known service instances without placing the Mesh API in the request path. +`MagicControl.Client` is the application-side NuGet package. It initializes MagicSettings, maintains the application's MagicControl identity, discovers ordinary applications directly, refreshes signed group manifests when a control plane is available, authorizes peer requests from local cached state, and resolves service instances without placing Mesh in the application request path. Install the package: @@ -19,14 +19,15 @@ var magicControl = await builder.AddMagicControlClientAsync { - settings.ApplicationId = "Orders"; settings.Template = new MyApplicationSettings(); }, configureClient: client => { client.GroupId = Guid.Parse("00000000-0000-0000-0000-000000000000"); client.ApplicationName = "Orders"; - client.AddMeshEndpoint("https://magic-control-mesh.example.local"); + client.AdvertiseEndpoint( + "https://orders.internal.example:7443", + isLan: true); }); if (magicControl.ShouldExit) @@ -35,7 +36,11 @@ if (magicControl.ShouldExit) } ``` -Applications can protect ASP.NET Core endpoints with `[RequireMagicControlMember]` or `[RequireMagicControlCapability("capability.name")]`. `IMagicControlAuthorizationService` is available for manual authorization and directory resolution. +`GroupId` and `ApplicationName` are intentionally configured. Mesh endpoints are discovered automatically on ordinary LANs; `AddMeshEndpointOverride(...)` is available only when a routed or multicast-restricted environment needs an explicit seed. + +Even with no MagicControl Web deployment, no Mesh API, and no cached signed directory, applications can discover one another through identity-signed direct peer advertisements. Resolver results expose whether a route is merely `IdentityVerified` or is `AuthorityApproved` by a secured cached manifest. + +Applications can protect ASP.NET Core endpoints with `[RequireMagicControlMember]` or `[RequireMagicControlCapability("capability.name")]`. Direct discovery never grants these authority-backed permissions. `IMagicControlAuthorizationService` is available for manual authorization, and `IMagicControlServiceResolver` handles signed-directory and direct-peer route selection. Offline trust is infinite by default. A secured group may instead configure a finite offline trust period from the MagicControl Web control pane. @@ -47,11 +52,12 @@ Offline trust is infinite by default. A secured group may instead configure a fi - Cookie authentication, forced password changes, and local-only administrator recovery. - User, role, enrollment, managed-instance, and group-policy administration. - Signed application and Mesh API enrollment using MagicSettings node identities. +- Direct identity-signed application discovery without Web or Mesh. - Signed multi-group manifests and encrypted last-known-good caches. - Open directory discovery and secured-only distributed settings. - Local cached peer authentication and capability authorization. - Audit records and health checks. -MagicControl Web remains the durable control-plane authority. The Mesh API distributes and caches signed state but is not a required application traffic proxy. +MagicControl Web remains the durable control-plane authority. Mesh distributes and caches signed state but is not a required application traffic proxy or a prerequisite for ordinary direct LAN discovery. -See [`docs/foundation.md`](docs/foundation.md) and [`docs/mesh-architecture.md`](docs/mesh-architecture.md) for setup, security, and architecture details. +See [`docs/client-platform.md`](docs/client-platform.md), [`docs/foundation.md`](docs/foundation.md), and [`docs/mesh-architecture.md`](docs/mesh-architecture.md) for setup, security, and architecture details. From b07bf23edef46b555d4f79a46a5779434609c041 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:58:59 -0400 Subject: [PATCH 15/35] Restrict peer directory mutation --- MagicControl/Client/Discovery/MagicControlPeerDirectory.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs b/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs index 6381318..cc08c69 100644 --- a/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs +++ b/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs @@ -30,7 +30,7 @@ public sealed class MagicControlPeerDirectory(MagicControlClientOptions options) private readonly Dictionary _observations = new(StringComparer.OrdinalIgnoreCase); - public bool Accept( + internal bool Accept( SignedMagicControlPeerAdvertisement envelope, DateTimeOffset receivedUtc, bool loadedFromDisk = false) @@ -80,7 +80,7 @@ public IReadOnlyList GetActive( } } - public IReadOnlyList Snapshot() + internal IReadOnlyList Snapshot() { lock (_gate) { From 5ae9ca4c809f0a567068bba05eb9eb8786fafbdb Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:59:32 -0400 Subject: [PATCH 16/35] Correct peer cache test setup --- .../Tests/MagicControl.Tests/PeerDiscoveryTests.cs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs index 0b43f0b..ce98475 100644 --- a/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs +++ b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs @@ -113,7 +113,8 @@ public async Task PeerDirectoryStore_EncryptsAndReloadsVerifiedObservations() Path.GetTempPath(), "magic-control-peer-cache", Guid.NewGuid().ToString("N")); - var options = CreateOptions().WithStatePath(statePath); + var options = CreateOptions(); + options.StatePath = statePath; var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); var observation = new MagicControlPeerObservation( signed, @@ -166,14 +167,6 @@ private static MagicControlClientOptions CreateOptions() return options; } - private static MagicControlClientOptions WithStatePath( - this MagicControlClientOptions options, - string statePath) - { - options.StatePath = statePath; - return options; - } - private static MagicControlManifestCache CreateManifestCache( Guid groupId, IReadOnlyList members) From 637cae5a7c10a7d5d358141f0c6b4be4e59604bb Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:00:25 -0400 Subject: [PATCH 17/35] Document direct peer trust model --- docs/mesh-architecture.md | 63 ++++++++++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/docs/mesh-architecture.md b/docs/mesh-architecture.md index 4a9e8df..f54d567 100644 --- a/docs/mesh-architecture.md +++ b/docs/mesh-architecture.md @@ -7,19 +7,21 @@ MagicControl Web is the durable authority. Mesh APIs are nearby discovery, relay Applications communicate directly and use `MagicControl.Client` to: - initialize MagicSettings and preserve local-only operation; +- discover ordinary applications directly; - discover Mesh APIs; - establish secured enrollment after administrator approval; - cache signed authorization state and offline-safe settings; - authenticate peers locally; - advertise service endpoints; -- resolve and select trusted application instances. +- resolve and select application instances with explicit trust metadata. The authority order is: 1. MagicControl Web owns group membership, approved credentials, capabilities, settings publication, security epochs, and revocation state. 2. Approved Mesh APIs retrieve signed manifests from Web and relay proof-bound node synchronization. 3. Clients verify signed manifests and perform request-time authorization entirely in process. -4. During outages, clients and Mesh continue using last-known-good approved state according to the authority-signed offline policy. +4. Direct peer discovery proves possession of an advertised MagicSettings identity but does not create authority approval. +5. During outages, clients and Mesh continue using last-known-good approved state according to the authority-signed offline policy. ## SDK-only local operation @@ -27,6 +29,8 @@ MagicControl connectivity is additive. `AddMagicControlClientAsync` a If no Mesh endpoint is discovered and no approved cache exists, synchronization returns `Disconnected`, the remote layer remains empty, and the application continues using local MagicSettings. This is normal SDK-only mode rather than a startup fault. +Applications with advertised endpoints also continue participating in direct peer discovery. Two clients that share a configured `GroupId` can discover one another without Web, Mesh, or a signed cached directory. + Applications may choose stricter startup behavior: - `CachedFirst` starts from local or cached state and refreshes in the background; @@ -43,7 +47,41 @@ The normal LAN path does not require a configured Mesh URL. Mesh advertises a re Explicit endpoints are for routed networks, multicast restrictions, public domains, or tests. They supplement discovery rather than disabling it. -Discovery is not trust. An untrusted responder is only a candidate transport. Secured state is accepted only after proof-bound approval and authority-signature validation. +Mesh discovery is not trust. An untrusted responder is only a candidate transport. Secured state is accepted only after proof-bound approval and authority-signature validation. + +## Direct application discovery + +Direct application discovery is a separate client-to-client multicast protocol and does not depend on a Mesh process listening on the LAN. + +A peer advertisement contains: + +- protocol version, group, and application identity; +- instance, role, site, and version metadata; +- the MagicSettings node identity and public credential; +- service endpoints, priorities, and route classification; +- a monotonic sequence, issue time, and short TTL; +- a MagicSettings proof signed over the exact serialized advertisement body and logical discovery URI. + +Receivers verify: + +- the configured group; +- permitted endpoint schemes; +- node and credential binding; +- public-key fingerprint; +- proof version, audience, method, target, body hash, lifetime, and nonce; +- ECDSA P-256 signature. + +Valid observations enter a separate encrypted peer cache with a short configurable lifetime. They never enter or modify the signed authority-manifest cache. + +The resolver assigns explicit trust: + +- `IdentityVerified` — the advertisement is cryptographically bound to the advertised persistent identity, but no authority approval is available; +- `AuthorityDirectory` — the route came from an authority-signed open-group directory; +- `AuthorityApproved` — the route is backed by an approved or retiring credential in a usable secured manifest. + +When no usable manifest exists, identity-verified peers are returned by default so ordinary standalone applications can find one another. This behavior can be disabled with `AllowIdentityVerifiedPeersWithoutAuthority = false`. + +When a secured manifest exists, a direct advertisement is returned only if its node ID, credential ID, public key, application name, and credential status match an approved manifest member. Discovery never bypasses revocation or secured membership. ## Secured bootstrap and authority pinning @@ -105,7 +143,9 @@ Offline trust is infinite by default. A null `MaximumOfflineSeconds` means that Administrators may configure a finite duration per group. The lease is anchored to the authority-signed manifest issue time. Repeatedly contacting an offline Mesh cannot extend the lease. -Client and Mesh cache files are encrypted with ASP.NET Core Data Protection and restricted to the current Unix account. Authority signatures and membership are revalidated after decryption. +Client and Mesh authority cache files are encrypted with ASP.NET Core Data Protection and restricted to the current Unix account. Authority signatures and membership are revalidated after decryption. + +The direct peer cache has a separate short duration even when authority offline trust is infinite. Identity observations are not authority grants and do not inherit the authority manifest's lease. ## Open and secured groups @@ -135,18 +175,20 @@ No Web or Mesh request occurs during controller authorization. Applications may - `IMagicControlAuthorizationService` for manual authorization; - `IMagicControlServiceResolver` for route selection. +Identity-verified discovery results do not satisfy membership or capability authorization. Applications that choose to call unmanaged peers must make that decision explicitly from resolver trust metadata. + ## Endpoint announcements and routing -Approved applications include signed endpoint announcements in normal synchronization. Web records endpoint priority, transport, loopback/LAN classification, sequence, and last-seen time. The signed directory gives records a finite expiration so crashed or disconnected instances age out. +Applications include endpoints in both direct peer advertisements and connected node synchronization. Web records connected endpoint priority, transport, loopback/LAN classification, sequence, and last-seen time. The signed directory gives records a finite expiration so crashed or disconnected instances age out. -The high-level resolver prefers: +The high-level resolver combines direct observations and signed directory records, then prefers: 1. loopback; 2. LAN; 3. private routed addresses; 4. public addresses. -It then applies configured priority and stable instance ordering. Round-robin is optional. Applications can report route failures to quarantine an endpoint temporarily. `ResolveAll` remains available for custom policies and always preserves duplicate application instances. +It applies configured priority, trust strength, source freshness, and stable instance ordering. Round-robin is optional. Applications can report route failures to quarantine an endpoint temporarily. `ResolveAll` remains available for custom policies and always preserves duplicate application instances. ## Mesh outage cache @@ -162,14 +204,15 @@ When Web is unavailable, Mesh may return that cached approved state only if: - the exact node and credential are members; - the group offline trust lease still permits use. -Pending enrollment, a new node, uncached settings, and live-only secrets require Web. +Pending enrollment, a new node, uncached settings, and live-only secrets require Web. Direct identity-verified application discovery remains available independently of the Mesh outage. ## Final security boundaries -- Mesh discovery identifies routes, never trust. +- Mesh discovery identifies control-plane routes, never trust. +- Direct peer discovery proves identity possession, never authority approval. - Administrator approval establishes the first secured authority relationship. - Web alone signs authoritative group state and publishes settings revisions. - Applications own their local schema and migrations. - Remote snapshots are complete replacement layers; omitted paths reveal lower MagicSettings providers. - Secrets are explicit, asynchronous, live-only values. -- Direct application traffic remains peer-to-peer and is locally authorized from signed cached state. +- Direct application traffic remains peer-to-peer; secured authorization remains locally enforced from signed cached state. From c383421d95628207c198562a5e4d68fc497cc62e Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:01:20 -0400 Subject: [PATCH 18/35] Prevent secured discovery downgrade --- .../Client/Discovery/MagicControlServiceResolver.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/MagicControl/Client/Discovery/MagicControlServiceResolver.cs b/MagicControl/Client/Discovery/MagicControlServiceResolver.cs index 86a632d..bedaab2 100644 --- a/MagicControl/Client/Discovery/MagicControlServiceResolver.cs +++ b/MagicControl/Client/Discovery/MagicControlServiceResolver.cs @@ -80,6 +80,7 @@ public IReadOnlyList ResolveAll( ArgumentException.ThrowIfNullOrWhiteSpace(applicationName); var now = nowUtc ?? DateTimeOffset.UtcNow; var state = _cache.Get(_options.GroupId); + var knownManifest = state?.Manifest; var usableManifest = state is not null && state.AllowsOfflineUse(now) ? state.Manifest : null; @@ -88,11 +89,12 @@ public IReadOnlyList ResolveAll( if (usableManifest is not null) { all.AddRange(FromSignedDirectory(usableManifest, applicationName, now)); + all.AddRange(FromDirectPeers(usableManifest, applicationName, now)); } - - if (usableManifest is not null || _options.AllowIdentityVerifiedPeersWithoutAuthority) + else if (knownManifest?.SecurityMode != MagicControlGroupSecurityMode.Secured + && _options.AllowIdentityVerifiedPeersWithoutAuthority) { - all.AddRange(FromDirectPeers(usableManifest, applicationName, now)); + all.AddRange(FromDirectPeers(null, applicationName, now)); } var deduplicated = all @@ -194,7 +196,7 @@ private IEnumerable FromDirectPeers( MagicControlMember? member = null; if (manifest is not null) { - member = manifest.Members.SingleOrDefault(candidate => + member = manifest.Members.FirstOrDefault(candidate => candidate.NodeId == advertisement.Identity.NodeId && candidate.CredentialId == advertisement.Identity.CredentialId && candidate.CredentialStatus is MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring From f1ab03ce949218ccbd8ca9ede797be5d91f01aa0 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:02:48 -0400 Subject: [PATCH 19/35] Test expired secured policy remains fail closed --- .../MagicControl.Tests/PeerDiscoveryTests.cs | 68 +++++++++++++------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs index ce98475..b4dfe68 100644 --- a/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs +++ b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs @@ -77,21 +77,7 @@ public void ServiceResolver_SecuredManifestUpgradesExactApprovedPeer() { var options = CreateOptions(); var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); - var advertisement = signed.Advertisement; - var member = new MagicControlMember( - Guid.NewGuid(), - EnrollmentKind.ApplicationInstance, - advertisement.DisplayName, - advertisement.ApplicationName, - advertisement.InstanceName, - advertisement.InstanceRole, - advertisement.SiteName, - advertisement.Identity.NodeId, - advertisement.Identity.CredentialId, - advertisement.Identity.PublicKey, - MagicCredentialStatus.Approved, - [], - ["orders.read"]); + var member = CreateApprovedMember(signed.Advertisement); var cache = CreateManifestCache(options.GroupId, [member]); var peers = new MagicControlPeerDirectory(options); Assert.True(peers.Accept(signed, DateTimeOffset.UtcNow)); @@ -106,6 +92,27 @@ public void ServiceResolver_SecuredManifestUpgradesExactApprovedPeer() Assert.Equal(member.ManagedInstanceId, result.Instance.ManagedInstanceId); } + [Fact] + public void ServiceResolver_ExpiredSecuredManifestDoesNotDowngradeToIdentityOnly() + { + var options = CreateOptions(); + var signed = CreateSignedAdvertisement(options.GroupId, "Orders"); + var member = CreateApprovedMember(signed.Advertisement); + var now = DateTimeOffset.UtcNow; + var cache = CreateManifestCache( + options.GroupId, + [member], + issuedUtc: now.AddMinutes(-5), + offlineTrust: MagicControlOfflineTrustPolicy.For(TimeSpan.FromSeconds(30))); + var peers = new MagicControlPeerDirectory(options); + Assert.True(peers.Accept(signed, now)); + var resolver = new MagicControlServiceResolver(options, cache, peers); + + var result = resolver.Resolve("Orders", now); + + Assert.Null(result); + } + [Fact] public async Task PeerDirectoryStore_EncryptsAndReloadsVerifiedObservations() { @@ -167,27 +174,46 @@ private static MagicControlClientOptions CreateOptions() return options; } + private static MagicControlMember CreateApprovedMember( + MagicControlPeerAdvertisement advertisement) + => new( + Guid.NewGuid(), + EnrollmentKind.ApplicationInstance, + advertisement.DisplayName, + advertisement.ApplicationName, + advertisement.InstanceName, + advertisement.InstanceRole, + advertisement.SiteName, + advertisement.Identity.NodeId, + advertisement.Identity.CredentialId, + advertisement.Identity.PublicKey, + MagicCredentialStatus.Approved, + [], + ["orders.read"]); + private static MagicControlManifestCache CreateManifestCache( Guid groupId, - IReadOnlyList members) + IReadOnlyList members, + DateTimeOffset? issuedUtc = null, + MagicControlOfflineTrustPolicy? offlineTrust = null) { - var now = DateTimeOffset.UtcNow; + var issued = issuedUtc ?? DateTimeOffset.UtcNow; var manifest = new MagicControlGroupManifest( groupId, "Home", MagicControlGroupSecurityMode.Secured, Guid.NewGuid(), 1, - now, - MagicControlOfflineTrustPolicy.Infinite, + issued, + offlineTrust ?? MagicControlOfflineTrustPolicy.Infinite, members, [], - MagicControlSettingsSnapshot.Empty(now)); + MagicControlSettingsSnapshot.Empty(issued)); using var authority = ECDsa.Create(ECCurve.NamedCurves.nistP256); var cache = new MagicControlManifestCache(); cache.Set(new MagicControlManifestState( MagicControlManifestCryptography.Sign(manifest, authority), - now, + issued, LoadedFromDisk: false)); return cache; } From cb6f2999e8d3a5de11c6059fa9d7c5c5037705e3 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:06:45 -0400 Subject: [PATCH 20/35] Make MagicControl authorization dynamically secure --- .../MagicControlNodeAuthentication.cs | 73 +++++++++++++++---- 1 file changed, 59 insertions(+), 14 deletions(-) diff --git a/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs b/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs index 898ee13..6d3db14 100644 --- a/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs +++ b/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs @@ -125,12 +125,18 @@ private async ValueTask ComputeBodyHashAsync(CancellationToken cancellat } } +public static class MagicControlAuthorizationPolicies +{ + public const string Member = "MagicControl.Member"; +} + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] public sealed class RequireMagicControlMemberAttribute : AuthorizeAttribute { public RequireMagicControlMemberAttribute() { AuthenticationSchemes = MagicControlMeshProtocol.NodeAuthenticationScheme; + Policy = MagicControlAuthorizationPolicies.Member; } } @@ -145,22 +151,53 @@ public RequireMagicControlCapabilityAttribute(string capability) } } -public sealed record MagicControlCapabilityRequirement(string Capability) : IAuthorizationRequirement; - -public sealed class MagicControlCapabilityHandler - : AuthorizationHandler +/// +/// Represents an application endpoint that is open until a signed secured-group policy is known. +/// Once a secured manifest arrives, the same policy immediately requires an approved member and, +/// when specified, the requested capability. A known secured policy never downgrades after expiry. +/// +public sealed record MagicControlAccessRequirement(string? Capability) : IAuthorizationRequirement; + +public sealed class MagicControlAccessHandler( + MagicControlClientOptions options, + MagicControlManifestCache cache) + : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, - MagicControlCapabilityRequirement requirement) + MagicControlAccessRequirement requirement) { - if (context.User.HasClaim( + var state = cache.Get(options.GroupId); + + // With no authority state, or when the authority explicitly declares this group Open, + // MagicControl is additive and does not make the application require credentials. + if (state is null + || state.Manifest.SecurityMode == MagicControlGroupSecurityMode.Open) + { + context.Succeed(requirement); + return Task.CompletedTask; + } + + // A known Secured policy remains secured even when its finite offline lease expires. + // Authentication will also fail because the credential registry refuses expired state. + if (!state.AllowsOfflineUse(DateTimeOffset.UtcNow) + || context.User.Identity?.IsAuthenticated != true + || !context.User.HasClaim( + MagicControlMeshProtocol.GroupIdClaim, + options.GroupId.ToString("D"))) + { + return Task.CompletedTask; + } + + if (!string.IsNullOrWhiteSpace(requirement.Capability) + && !context.User.HasClaim( MagicControlMeshProtocol.CapabilityClaim, requirement.Capability)) { - context.Succeed(requirement); + return Task.CompletedTask; } + context.Succeed(requirement); return Task.CompletedTask; } } @@ -173,6 +210,14 @@ public sealed class MagicControlCapabilityPolicyProvider( public Task GetPolicyAsync(string policyName) { + if (string.Equals( + policyName, + MagicControlAuthorizationPolicies.Member, + StringComparison.Ordinal)) + { + return Task.FromResult(BuildPolicy(capability: null)); + } + if (!policyName.StartsWith( MagicControlMeshProtocol.CapabilityPolicyPrefix, StringComparison.Ordinal)) @@ -181,13 +226,7 @@ public sealed class MagicControlCapabilityPolicyProvider( } var capability = policyName[MagicControlMeshProtocol.CapabilityPolicyPrefix.Length..]; - var policy = new AuthorizationPolicyBuilder( - MagicControlMeshProtocol.NodeAuthenticationScheme) - .RequireAuthenticatedUser() - .AddRequirements(new MagicControlCapabilityRequirement(capability)) - .Build(); - - return Task.FromResult(policy); + return Task.FromResult(BuildPolicy(capability)); } public Task GetDefaultPolicyAsync() @@ -195,4 +234,10 @@ public Task GetDefaultPolicyAsync() public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync(); + + private static AuthorizationPolicy BuildPolicy(string? capability) + => new AuthorizationPolicyBuilder( + MagicControlMeshProtocol.NodeAuthenticationScheme) + .AddRequirements(new MagicControlAccessRequirement(capability)) + .Build(); } From db57157ce18e2168c6fb870b50ff44f34ff0de23 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:08:05 -0400 Subject: [PATCH 21/35] Persist sticky secured application state --- .../Client/State/MagicControlSecurityLatch.cs | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 MagicControl/Client/State/MagicControlSecurityLatch.cs diff --git a/MagicControl/Client/State/MagicControlSecurityLatch.cs b/MagicControl/Client/State/MagicControlSecurityLatch.cs new file mode 100644 index 0000000..59ef643 --- /dev/null +++ b/MagicControl/Client/State/MagicControlSecurityLatch.cs @@ -0,0 +1,148 @@ +using System.Text; +using MagicControl.Shared.Mesh; + +namespace MagicControl.Client; + +/// +/// A one-way local security marker. Its contents are informational; the existence of the +/// restricted file is the latch so truncated or partially corrupted state remains fail-closed. +/// Only a successfully validated signed Open manifest may clear it. +/// +public interface IMagicControlSecurityLatchStore +{ + bool IsLatched { get; } + + ValueTask LatchAsync( + SignedMagicControlGroupManifest manifest, + CancellationToken cancellationToken = default); + + ValueTask ClearAsync(CancellationToken cancellationToken = default); +} + +public sealed class FileMagicControlSecurityLatchStore : IMagicControlSecurityLatchStore +{ + private readonly string _path; + private readonly SemaphoreSlim _gate = new(1, 1); + + public FileMagicControlSecurityLatchStore(MagicControlClientOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _path = Path.GetFullPath(Path.Combine(options.StatePath, options.SecurityLatchFileName)); + } + + public bool IsLatched => File.Exists(_path); + + public async ValueTask LatchAsync( + SignedMagicControlGroupManifest manifest, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + await _gate.WaitAsync(cancellationToken); + try + { + var directory = Path.GetDirectoryName(_path)!; + Directory.CreateDirectory(directory); + RestrictDirectory(directory); + + var payload = string.Join( + '\n', + "MAGICCONTROL-SECURED-POLICY-V1", + manifest.Manifest.GroupId.ToString("D"), + manifest.AuthorityKeyId, + manifest.Manifest.SecurityEpoch.ToString("D"), + manifest.Manifest.Revision.ToString(System.Globalization.CultureInfo.InvariantCulture), + DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture), + string.Empty); + var temporaryPath = _path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + await File.WriteAllTextAsync( + temporaryPath, + payload, + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + cancellationToken); + RestrictFile(temporaryPath); + File.Move(temporaryPath, _path, overwrite: true); + RestrictFile(_path); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + finally + { + _gate.Release(); + } + } + + public async ValueTask ClearAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (File.Exists(_path)) + { + File.Delete(_path); + } + } + finally + { + _gate.Release(); + } + } + + private static void RestrictFile(string path) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } + + private static void RestrictDirectory(string path) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode( + path, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + } +} + +public sealed class MagicControlRuntimeSecurityState( + IMagicControlSecurityLatchStore latchStore) +{ + private int _requiresAuthorization = latchStore.IsLatched ? 1 : 0; + + public bool RequiresAuthorization => Volatile.Read(ref _requiresAuthorization) == 1; + + /// + /// Applies a manifest only after the normal signature, authority pin, group, and membership + /// validation path has accepted it. + /// + public async ValueTask ApplyValidatedManifestAsync( + SignedMagicControlGroupManifest manifest, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(manifest); + + if (manifest.Manifest.SecurityMode == MagicControlGroupSecurityMode.Secured) + { + // Close the in-memory gate before touching disk so the next request is secured. + Volatile.Write(ref _requiresAuthorization, 1); + await latchStore.LatchAsync(manifest, cancellationToken); + return; + } + + // Opening is intentionally the reverse order: remove the persistent latch first, then + // expose Open mode in memory. Callers must only invoke this for a validated authority + // manifest, never because discovery or connectivity disappeared. + await latchStore.ClearAsync(cancellationToken); + Volatile.Write(ref _requiresAuthorization, 0); + } +} From 8dda9c3726896a19afeaa1e74424eeecd9dcb3e6 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:08:28 -0400 Subject: [PATCH 22/35] Configure persistent secured policy latch --- .../Configuration/MagicControlClientOptions.cs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/MagicControl/Client/Configuration/MagicControlClientOptions.cs b/MagicControl/Client/Configuration/MagicControlClientOptions.cs index 153e5f8..c612117 100644 --- a/MagicControl/Client/Configuration/MagicControlClientOptions.cs +++ b/MagicControl/Client/Configuration/MagicControlClientOptions.cs @@ -33,6 +33,13 @@ public sealed class MagicControlClientOptions public string ClientStateFileName { get; set; } = "client-state.protected"; public string PeerDirectoryFileName { get; set; } = "peer-directory.protected"; + /// + /// Presence of this non-secret, permission-restricted marker means this application has + /// accepted a signed Secured policy and must not fall back to open behavior merely because + /// authority state is temporarily unavailable or unreadable. + /// + public string SecurityLatchFileName { get; set; } = "secured-policy.lock"; + public MagicControlStartupMode StartupMode { get; set; } = MagicControlStartupMode.CachedFirst; public MagicControlRouteSelectionMode RouteSelection { get; set; } = MagicControlRouteSelectionMode.Automatic; public TimeSpan RefreshInterval { get; set; } = TimeSpan.FromSeconds(30); @@ -57,8 +64,8 @@ public sealed class MagicControlClientOptions /// /// Allows identity-verified direct peers to be returned when no usable authority manifest - /// exists. This never grants MagicControl membership or capabilities; secured authorization - /// attributes still require authority-approved cached state. + /// exists and this application has never accepted a signed Secured policy. This never grants + /// membership or capabilities. A sticky secured policy always takes precedence. /// public bool AllowIdentityVerifiedPeersWithoutAuthority { get; set; } = true; @@ -130,6 +137,11 @@ internal void Validate() throw new InvalidOperationException("MagicControl client ApplicationName is required."); } + if (string.IsNullOrWhiteSpace(SecurityLatchFileName)) + { + throw new InvalidOperationException("MagicControl security latch file name is required."); + } + DisplayName = string.IsNullOrWhiteSpace(DisplayName) ? $"{ApplicationName} on {Environment.MachineName}" : DisplayName.Trim(); From d0060afe8d1194a8d665f81faefb4b1ea34fc5d2 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:09:08 -0400 Subject: [PATCH 23/35] Wire sticky runtime security state --- .../MagicControlClientExtensions.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/MagicControl/Client/Configuration/MagicControlClientExtensions.cs b/MagicControl/Client/Configuration/MagicControlClientExtensions.cs index 03bd78e..d70b22a 100644 --- a/MagicControl/Client/Configuration/MagicControlClientExtensions.cs +++ b/MagicControl/Client/Configuration/MagicControlClientExtensions.cs @@ -34,6 +34,8 @@ public static async ValueTask AddMagicControl var clientStateStore = new FileMagicControlClientStateStore(clientOptions); var peerDirectory = new MagicControlPeerDirectory(clientOptions); var peerDirectoryStore = new FileMagicControlPeerDirectoryStore(clientOptions); + var securityLatchStore = new FileMagicControlSecurityLatchStore(clientOptions); + var securityState = new MagicControlRuntimeSecurityState(securityLatchStore); var persistentState = await clientStateStore.LoadAsync(cancellationToken); clientOptions.TrustedAuthorityPublicKey ??= persistentState.AuthorityPublicKey; var contextHash = clientOptions.ComputeContextHash(persistentState.BootstrapNonce); @@ -50,7 +52,8 @@ public static async ValueTask AddMagicControl manifestStore, validator, cache, - status); + status, + securityState); var logicalEndpointResolver = new MagicControlLogicalEndpointResolver( clientOptions, contextHash); @@ -90,6 +93,8 @@ public static async ValueTask AddMagicControl clientStateStore, peerDirectory, peerDirectoryStore, + securityLatchStore, + securityState, validator, endpointResolver, status, @@ -113,6 +118,8 @@ public static IServiceCollection AddMagicControlClient( var stateStore = new FileMagicControlClientStateStore(options); var peerDirectory = new MagicControlPeerDirectory(options); var peerDirectoryStore = new FileMagicControlPeerDirectoryStore(options); + var securityLatchStore = new FileMagicControlSecurityLatchStore(options); + var securityState = new MagicControlRuntimeSecurityState(securityLatchStore); var validator = new MagicControlManifestValidator(options); var status = new MagicControlClientStatus(); var resolver = new DiscoveringMagicControlMeshEndpointResolver(options, stateStore); @@ -124,6 +131,8 @@ public static IServiceCollection AddMagicControlClient( services.AddSingleton(stateStore); services.AddSingleton(peerDirectory); services.AddSingleton(peerDirectoryStore); + services.AddSingleton(securityLatchStore); + services.AddSingleton(securityState); services.AddSingleton(validator); services.AddSingleton(resolver); services.AddSingleton(status); @@ -157,7 +166,7 @@ public static IServiceCollection AddMagicControlNodeAuthorization( _ => { }); services.AddAuthorization(); services.TryAddEnumerable( - ServiceDescriptor.Singleton()); + ServiceDescriptor.Singleton()); services.Replace(ServiceDescriptor.Singleton()); @@ -172,6 +181,8 @@ private static void RegisterClientServices( FileMagicControlClientStateStore stateStore, MagicControlPeerDirectory peerDirectory, FileMagicControlPeerDirectoryStore peerDirectoryStore, + FileMagicControlSecurityLatchStore securityLatchStore, + MagicControlRuntimeSecurityState securityState, MagicControlManifestValidator validator, DiscoveringMagicControlMeshEndpointResolver endpointResolver, MagicControlClientStatus status, @@ -184,6 +195,8 @@ private static void RegisterClientServices( services.AddSingleton(stateStore); services.AddSingleton(peerDirectory); services.AddSingleton(peerDirectoryStore); + services.AddSingleton(securityLatchStore); + services.AddSingleton(securityState); services.AddSingleton(validator); services.AddSingleton(endpointResolver); services.AddSingleton(status); From 0203fe1ec3e81703d06e2f6147cf112212a063b5 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:09:38 -0400 Subject: [PATCH 24/35] Apply validated security policy during sync --- .../ControlPlane/MagicControlClientSyncTransport.cs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs b/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs index c04688e..50be38d 100644 --- a/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs +++ b/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs @@ -17,6 +17,7 @@ public sealed class MagicControlClientSyncTransport : private readonly MagicControlManifestValidator _manifestValidator; private readonly MagicControlManifestCache _manifestCache; private readonly MagicControlClientStatus _status; + private readonly MagicControlRuntimeSecurityState _securityState; private readonly HttpClient _httpClient; private readonly SemaphoreSlim _gate = new(1, 1); @@ -27,7 +28,8 @@ public MagicControlClientSyncTransport( IMagicControlManifestStore manifestStore, MagicControlManifestValidator manifestValidator, MagicControlManifestCache manifestCache, - MagicControlClientStatus status) + MagicControlClientStatus status, + MagicControlRuntimeSecurityState securityState) { _options = options; _endpointResolver = endpointResolver; @@ -36,6 +38,7 @@ public MagicControlClientSyncTransport( _manifestValidator = manifestValidator; _manifestCache = manifestCache; _status = status; + _securityState = securityState; _httpClient = new HttpClient(new SocketsHttpHandler { @@ -222,6 +225,9 @@ private async ValueTask SynchronizeThroughAsync( validation.Error ?? "MagicControl returned an invalid signed manifest."); } + // Apply security policy before publishing the manifest to request-time consumers. + // Secured closes immediately and persists; Open clears the persistent latch first. + await _securityState.ApplyValidatedManifestAsync(manifest, cancellationToken); await _manifestStore.SaveAsync(stored, cancellationToken); _manifestCache.Set(new MagicControlManifestState( manifest, @@ -303,6 +309,7 @@ private async ValueTask CreateOfflineResponseAsync( cancellationToken); if (validation.IsValid) { + await _securityState.ApplyValidatedManifestAsync(stored.Envelope, cancellationToken); _manifestCache.Set(new MagicControlManifestState( stored.Envelope, stored.LastAuthorityContactUtc, @@ -317,6 +324,7 @@ private async ValueTask CreateOfflineResponseAsync( } } + // Do not clear the secured latch just because the manifest or control plane is missing. _status.RecordEnrollment(MagicControlEnrollmentState.LocalOnly, reason); return new MagicSettingsSyncResponse( MagicControlPlaneState.Disconnected, From e239ec769505afd5bd099b3c58cab2fb6499d2ef Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:09:57 -0400 Subject: [PATCH 25/35] Keep secured state across startup and outages --- MagicControl/Client/Runtime/MagicControlClientRuntime.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/MagicControl/Client/Runtime/MagicControlClientRuntime.cs b/MagicControl/Client/Runtime/MagicControlClientRuntime.cs index 5944c62..07c656a 100644 --- a/MagicControl/Client/Runtime/MagicControlClientRuntime.cs +++ b/MagicControl/Client/Runtime/MagicControlClientRuntime.cs @@ -101,6 +101,7 @@ public sealed class MagicControlClientHostedService( IHttpClientFactory httpClientFactory, IServiceProvider serviceProvider, MagicControlClientStatus status, + MagicControlRuntimeSecurityState securityState, ILogger logger) : BackgroundService { private IMagicSettingsControlPlane? _controlPlane; @@ -159,6 +160,7 @@ private async ValueTask LoadCachedManifestAsync(CancellationToken cancella var stored = await store.LoadAsync(cancellationToken); if (stored is null) { + // A persistent secured latch, if present, intentionally remains active. return false; } @@ -172,9 +174,11 @@ private async ValueTask LoadCachedManifestAsync(CancellationToken cancella logger.LogWarning( "The cached MagicControl manifest was ignored: {Reason}", validation.Error); + // Invalid or expired cache never clears a previously secured policy. return false; } + await securityState.ApplyValidatedManifestAsync(stored.Envelope, cancellationToken); cache.Set(new MagicControlManifestState( stored.Envelope, stored.LastAuthorityContactUtc, @@ -244,6 +248,7 @@ private async Task TryLegacyManifestRefreshAsync(CancellationToken cancell validation.Error ?? "The Mesh API returned an invalid group manifest."); } + await securityState.ApplyValidatedManifestAsync(envelope, cancellationToken); await store.SaveAsync(stored, cancellationToken); cache.Set(new MagicControlManifestState(envelope, now, LoadedFromDisk: false)); status.RecordSuccess(endpoint, now); From 224b7c0739e7e5b003e6c4fccf4e36f0f81bd38f Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:10:22 -0400 Subject: [PATCH 26/35] Honor sticky secured policy in request authorization --- .../MagicControlNodeAuthentication.cs | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs b/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs index 6d3db14..369e4b4 100644 --- a/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs +++ b/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs @@ -152,39 +152,47 @@ public RequireMagicControlCapabilityAttribute(string capability) } /// -/// Represents an application endpoint that is open until a signed secured-group policy is known. -/// Once a secured manifest arrives, the same policy immediately requires an approved member and, -/// when specified, the requested capability. A known secured policy never downgrades after expiry. +/// Represents an endpoint that is open until a signed secured-group policy is accepted. Once +/// secured, it remains secured through outages, restarts, missing caches, and expired leases until +/// a validated authority manifest explicitly declares the group Open. /// public sealed record MagicControlAccessRequirement(string? Capability) : IAuthorizationRequirement; public sealed class MagicControlAccessHandler( - MagicControlClientOptions options, - MagicControlManifestCache cache) + MagicControlManifestCache cache, + MagicControlRuntimeSecurityState securityState) : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, MagicControlAccessRequirement requirement) { - var state = cache.Get(options.GroupId); + var states = cache.GetAll(); + var securedStates = states + .Where(state => state.Manifest.SecurityMode == MagicControlGroupSecurityMode.Secured) + .ToArray(); - // With no authority state, or when the authority explicitly declares this group Open, - // MagicControl is additive and does not make the application require credentials. - if (state is null - || state.Manifest.SecurityMode == MagicControlGroupSecurityMode.Open) + // Before any authority has secured the application, MagicControl is additive. A signed + // Open manifest also leaves these endpoints open. The persistent latch prevents absence + // or corruption of the manifest cache from being interpreted as permission to reopen. + if (!securityState.RequiresAuthorization && securedStates.Length == 0) { context.Succeed(requirement); return Task.CompletedTask; } - // A known Secured policy remains secured even when its finite offline lease expires. - // Authentication will also fail because the credential registry refuses expired state. - if (!state.AllowsOfflineUse(DateTimeOffset.UtcNow) - || context.User.Identity?.IsAuthenticated != true - || !context.User.HasClaim( + if (context.User.Identity?.IsAuthenticated != true) + { + return Task.CompletedTask; + } + + var now = DateTimeOffset.UtcNow; + var belongsToUsableSecuredGroup = securedStates.Any(state => + state.AllowsOfflineUse(now) + && context.User.HasClaim( MagicControlMeshProtocol.GroupIdClaim, - options.GroupId.ToString("D"))) + state.Manifest.GroupId.ToString("D"))); + if (!belongsToUsableSecuredGroup) { return Task.CompletedTask; } From 71f38488b6a0bcd6d3ea4178a8e940d6f1928888 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:10:57 -0400 Subject: [PATCH 27/35] Support in-memory security state for resolver tests --- .../Client/State/MagicControlSecurityLatch.cs | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/MagicControl/Client/State/MagicControlSecurityLatch.cs b/MagicControl/Client/State/MagicControlSecurityLatch.cs index 59ef643..b438387 100644 --- a/MagicControl/Client/State/MagicControlSecurityLatch.cs +++ b/MagicControl/Client/State/MagicControlSecurityLatch.cs @@ -114,10 +114,21 @@ private static void RestrictDirectory(string path) } } -public sealed class MagicControlRuntimeSecurityState( - IMagicControlSecurityLatchStore latchStore) +public sealed class MagicControlRuntimeSecurityState { - private int _requiresAuthorization = latchStore.IsLatched ? 1 : 0; + private readonly IMagicControlSecurityLatchStore _latchStore; + private int _requiresAuthorization; + + public MagicControlRuntimeSecurityState(IMagicControlSecurityLatchStore latchStore) + { + _latchStore = latchStore ?? throw new ArgumentNullException(nameof(latchStore)); + _requiresAuthorization = latchStore.IsLatched ? 1 : 0; + } + + internal MagicControlRuntimeSecurityState(bool requiresAuthorization = false) + : this(new InMemorySecurityLatchStore(requiresAuthorization)) + { + } public bool RequiresAuthorization => Volatile.Read(ref _requiresAuthorization) == 1; @@ -135,14 +146,36 @@ public async ValueTask ApplyValidatedManifestAsync( { // Close the in-memory gate before touching disk so the next request is secured. Volatile.Write(ref _requiresAuthorization, 1); - await latchStore.LatchAsync(manifest, cancellationToken); + await _latchStore.LatchAsync(manifest, cancellationToken); return; } // Opening is intentionally the reverse order: remove the persistent latch first, then // expose Open mode in memory. Callers must only invoke this for a validated authority // manifest, never because discovery or connectivity disappeared. - await latchStore.ClearAsync(cancellationToken); + await _latchStore.ClearAsync(cancellationToken); Volatile.Write(ref _requiresAuthorization, 0); } + + private sealed class InMemorySecurityLatchStore(bool isLatched) + : IMagicControlSecurityLatchStore + { + private bool _latched = isLatched; + + public bool IsLatched => _latched; + + public ValueTask LatchAsync( + SignedMagicControlGroupManifest manifest, + CancellationToken cancellationToken = default) + { + _latched = true; + return ValueTask.CompletedTask; + } + + public ValueTask ClearAsync(CancellationToken cancellationToken = default) + { + _latched = false; + return ValueTask.CompletedTask; + } + } } From d11effb4b5ed40f55aecbf2df0f1269a4eb3c881 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:11:26 -0400 Subject: [PATCH 28/35] Prevent secured clients from downgrading peer resolution --- .../Discovery/MagicControlServiceResolver.cs | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/MagicControl/Client/Discovery/MagicControlServiceResolver.cs b/MagicControl/Client/Discovery/MagicControlServiceResolver.cs index bedaab2..511b352 100644 --- a/MagicControl/Client/Discovery/MagicControlServiceResolver.cs +++ b/MagicControl/Client/Discovery/MagicControlServiceResolver.cs @@ -52,6 +52,7 @@ public sealed class MagicControlServiceResolver : IMagicControlServiceResolver private readonly MagicControlClientOptions _options; private readonly MagicControlManifestCache _cache; private readonly MagicControlPeerDirectory _peers; + private readonly MagicControlRuntimeSecurityState _securityState; private readonly object _gate = new(); private readonly Dictionary _roundRobin = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _unhealthyUntil = new(StringComparer.OrdinalIgnoreCase); @@ -59,7 +60,11 @@ public sealed class MagicControlServiceResolver : IMagicControlServiceResolver public MagicControlServiceResolver( MagicControlClientOptions options, MagicControlManifestCache cache) - : this(options, cache, new MagicControlPeerDirectory(options)) + : this( + options, + cache, + new MagicControlPeerDirectory(options), + new MagicControlRuntimeSecurityState()) { } @@ -67,10 +72,20 @@ public MagicControlServiceResolver( MagicControlClientOptions options, MagicControlManifestCache cache, MagicControlPeerDirectory peers) + : this(options, cache, peers, new MagicControlRuntimeSecurityState()) + { + } + + public MagicControlServiceResolver( + MagicControlClientOptions options, + MagicControlManifestCache cache, + MagicControlPeerDirectory peers, + MagicControlRuntimeSecurityState securityState) { _options = options; _cache = cache; _peers = peers; + _securityState = securityState; } public IReadOnlyList ResolveAll( @@ -84,14 +99,18 @@ public IReadOnlyList ResolveAll( var usableManifest = state is not null && state.AllowsOfflineUse(now) ? state.Manifest : null; + var securedPolicyKnown = _securityState.RequiresAuthorization + || knownManifest?.SecurityMode == MagicControlGroupSecurityMode.Secured; var all = new List(); - if (usableManifest is not null) + if (usableManifest is not null + && (usableManifest.SecurityMode == MagicControlGroupSecurityMode.Secured + || !_securityState.RequiresAuthorization)) { all.AddRange(FromSignedDirectory(usableManifest, applicationName, now)); all.AddRange(FromDirectPeers(usableManifest, applicationName, now)); } - else if (knownManifest?.SecurityMode != MagicControlGroupSecurityMode.Secured + else if (!securedPolicyKnown && _options.AllowIdentityVerifiedPeersWithoutAuthority) { all.AddRange(FromDirectPeers(null, applicationName, now)); From 231557adb0a9ddcd6adfc0ae61ffd52693914715 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:12:21 -0400 Subject: [PATCH 29/35] Test sticky open to secured runtime transitions --- .../SecurityTransitionTests.cs | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 MagicControl/Tests/MagicControl.Tests/SecurityTransitionTests.cs diff --git a/MagicControl/Tests/MagicControl.Tests/SecurityTransitionTests.cs b/MagicControl/Tests/MagicControl.Tests/SecurityTransitionTests.cs new file mode 100644 index 0000000..57e4382 --- /dev/null +++ b/MagicControl/Tests/MagicControl.Tests/SecurityTransitionTests.cs @@ -0,0 +1,236 @@ +using System.Globalization; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using MagicControl.Client; +using MagicControl.Shared.Mesh; +using MagicSettings.Share; +using Microsoft.AspNetCore.Authorization; + +namespace MagicControl.Tests; + +public sealed class SecurityTransitionTests +{ + [Fact] + public async Task Authorization_StartsOpenAndSwitchesToSecuredWithoutRestart() + { + var groupId = Guid.NewGuid(); + var cache = new MagicControlManifestCache(); + var security = new MagicControlRuntimeSecurityState(); + var handler = new MagicControlAccessHandler(cache, security); + var requirement = new MagicControlAccessRequirement("orders.read"); + + var initiallyOpen = CreateContext(requirement, new ClaimsPrincipal(new ClaimsIdentity())); + await handler.HandleAsync(initiallyOpen); + Assert.True(initiallyOpen.HasSucceeded); + + var secured = CreateManifest(groupId, MagicControlGroupSecurityMode.Secured); + await security.ApplyValidatedManifestAsync(secured); + cache.Set(new MagicControlManifestState( + secured, + DateTimeOffset.UtcNow, + LoadedFromDisk: false)); + + var anonymousAfterApproval = CreateContext( + requirement, + new ClaimsPrincipal(new ClaimsIdentity())); + await handler.HandleAsync(anonymousAfterApproval); + Assert.False(anonymousAfterApproval.HasSucceeded); + + var approvedIdentity = new ClaimsIdentity( + [ + new Claim(MagicControlMeshProtocol.GroupIdClaim, groupId.ToString("D")), + new Claim(MagicControlMeshProtocol.CapabilityClaim, "orders.read") + ], + MagicControlMeshProtocol.NodeAuthenticationScheme); + var approved = CreateContext(requirement, new ClaimsPrincipal(approvedIdentity)); + await handler.HandleAsync(approved); + Assert.True(approved.HasSucceeded); + } + + [Fact] + public async Task SecuredLatch_SurvivesRestartAndCorruptOrdinaryStateUntilValidatedOpenManifest() + { + var statePath = Path.Combine( + Path.GetTempPath(), + "magic-control-security-latch", + Guid.NewGuid().ToString("N")); + var options = new MagicControlClientOptions + { + GroupId = Guid.NewGuid(), + ApplicationName = "Orders", + StatePath = statePath, + EnableAutomaticDiscovery = false, + EnableDirectPeerDiscovery = false + }; + options.Validate(); + + try + { + var store = new FileMagicControlSecurityLatchStore(options); + var runtime = new MagicControlRuntimeSecurityState(store); + var secured = CreateManifest( + options.GroupId, + MagicControlGroupSecurityMode.Secured); + + await runtime.ApplyValidatedManifestAsync(secured); + Assert.True(runtime.RequiresAuthorization); + Assert.True(store.IsLatched); + + // The latch is defined by file presence, not parseable contents. A truncated marker + // therefore remains fail-closed rather than looking like a fresh unmanaged install. + var latchPath = Path.Combine(statePath, options.SecurityLatchFileName); + await File.WriteAllTextAsync(latchPath, string.Empty); + var restarted = new MagicControlRuntimeSecurityState( + new FileMagicControlSecurityLatchStore(options)); + Assert.True(restarted.RequiresAuthorization); + + var handler = new MagicControlAccessHandler( + new MagicControlManifestCache(), + restarted); + var denied = CreateContext( + new MagicControlAccessRequirement(null), + new ClaimsPrincipal(new ClaimsIdentity())); + await handler.HandleAsync(denied); + Assert.False(denied.HasSucceeded); + + var open = CreateManifest( + options.GroupId, + MagicControlGroupSecurityMode.Open); + await restarted.ApplyValidatedManifestAsync(open); + + Assert.False(restarted.RequiresAuthorization); + Assert.False(File.Exists(latchPath)); + + var opened = CreateContext( + new MagicControlAccessRequirement(null), + new ClaimsPrincipal(new ClaimsIdentity())); + await handler.HandleAsync(opened); + Assert.True(opened.HasSucceeded); + } + finally + { + if (Directory.Exists(statePath)) + { + Directory.Delete(statePath, recursive: true); + } + } + } + + [Fact] + public void ServiceResolver_DoesNotReturnIdentityOnlyPeersAfterSecuredLatch() + { + var options = new MagicControlClientOptions + { + GroupId = Guid.NewGuid(), + ApplicationName = "Consumer", + EnableAutomaticDiscovery = false, + EnableDirectPeerDiscovery = false + }; + options.Validate(); + + var peers = new MagicControlPeerDirectory(options); + Assert.True(peers.Accept( + CreateSignedAdvertisement(options.GroupId, "Orders"), + DateTimeOffset.UtcNow)); + var resolver = new MagicControlServiceResolver( + options, + new MagicControlManifestCache(), + peers, + new MagicControlRuntimeSecurityState(requiresAuthorization: true)); + + Assert.Null(resolver.Resolve("Orders")); + } + + private static AuthorizationHandlerContext CreateContext( + MagicControlAccessRequirement requirement, + ClaimsPrincipal principal) + => new([requirement], principal, resource: null); + + private static SignedMagicControlGroupManifest CreateManifest( + Guid groupId, + MagicControlGroupSecurityMode mode) + { + var now = DateTimeOffset.UtcNow; + var manifest = new MagicControlGroupManifest( + groupId, + "Home", + mode, + Guid.NewGuid(), + 1, + now, + MagicControlOfflineTrustPolicy.Infinite, + [], + [], + MagicControlSettingsSnapshot.Empty(now)); + using var authority = ECDsa.Create(ECCurve.NamedCurves.nistP256); + return MagicControlManifestCryptography.Sign(manifest, authority); + } + + private static SignedMagicControlPeerAdvertisement CreateSignedAdvertisement( + Guid groupId, + string applicationName) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var publicKey = Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()); + var now = DateTimeOffset.UtcNow; + var identity = new MagicNodeIdentityDescriptor( + Guid.NewGuid(), + Guid.NewGuid(), + MagicCredentialKind.EcdsaP256, + "ECDSA_P256_SHA256_P1363", + publicKey, + Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(publicKey))).ToLowerInvariant(), + now); + var advertisement = new MagicControlPeerAdvertisement( + MagicControlNodeProtocol.PeerDiscoveryProtocolVersion, + groupId, + applicationName, + $"{applicationName} primary", + "orders-1", + "api", + "home", + "1.0.0", + identity, + [new MagicControlServiceEndpointAnnouncement( + new Uri("https://192.168.10.25:7443"), + Priority: 10, + IsLan: true)], + 1, + now, + 20); + var unsigned = new MagicAuthenticationProof( + "MAGICSETTINGS-PROOF-V1", + identity.NodeId, + identity.CredentialId, + MagicControlNodeProtocol.PeerDiscoveryAudience, + "ANNOUNCE", + MagicControlPeerAdvertisementSecurity.Target(advertisement).AbsoluteUri, + MagicControlPeerAdvertisementSecurity.ComputeBodySha256(advertisement), + now, + now.AddMinutes(1), + "test-nonce", + string.Empty); + var signature = key.SignData( + Encoding.UTF8.GetBytes(Canonicalize(unsigned)), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return new SignedMagicControlPeerAdvertisement( + advertisement, + unsigned with { Signature = Convert.ToBase64String(signature) }); + } + + private static string Canonicalize(MagicAuthenticationProof proof) + => string.Join( + '\n', + proof.Version, + proof.NodeId.ToString("D"), + proof.CredentialId.ToString("D"), + proof.Audience, + proof.Method.ToUpperInvariant(), + proof.Target, + proof.BodySha256.ToLowerInvariant(), + proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture), + proof.Nonce); +} From c697330e948807236dac53e0b5d248f35f815a4a Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:13:43 -0400 Subject: [PATCH 30/35] Preserve standalone authorization registration compatibility --- .../Client/Configuration/MagicControlClientExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MagicControl/Client/Configuration/MagicControlClientExtensions.cs b/MagicControl/Client/Configuration/MagicControlClientExtensions.cs index d70b22a..16b5ed5 100644 --- a/MagicControl/Client/Configuration/MagicControlClientExtensions.cs +++ b/MagicControl/Client/Configuration/MagicControlClientExtensions.cs @@ -153,6 +153,7 @@ public static IServiceCollection AddMagicControlNodeAuthorization( services.TryAddSingleton(); services.TryAddSingleton(provider => provider.GetRequiredService()); + services.TryAddSingleton(_ => new MagicControlRuntimeSecurityState()); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); From b8df3c65d2e58c5f43ba3e1ac4276400c81c6b33 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:14:19 -0400 Subject: [PATCH 31/35] Document sticky open to secured transition --- docs/client-platform.md | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/client-platform.md b/docs/client-platform.md index 254500c..cf1a4d0 100644 --- a/docs/client-platform.md +++ b/docs/client-platform.md @@ -51,6 +51,7 @@ if (magic.ShouldExit) - Local environment variables and custom providers continue working. - Applications advertise and discover one another directly on the LAN when endpoints are configured. - The client also performs opportunistic Mesh discovery without making platform availability a startup requirement. +- Before the client has accepted a signed Secured policy, protected MagicControl endpoints behave as open application endpoints. - When no approved cached state and no Mesh are available, the application reports local-only status and continues normally. Use `RequireApprovedState` only for applications that must refuse startup without previously approved MagicControl state. @@ -86,7 +87,7 @@ Each application periodically advertises: A receiver verifies the public-key fingerprint, body hash, proof audience, method, target, lifetime, identity binding, and ECDSA signature before accepting the peer. Accepted observations are kept in memory and in a separate encrypted short-lived cache. -With no usable authority manifest, `IMagicControlServiceResolver` may return these routes with: +When no usable authority manifest exists **and the application has never accepted a signed Secured policy**, `IMagicControlServiceResolver` may return these routes with: ```csharp result.TrustLevel == MagicControlPeerTrustLevel.IdentityVerified @@ -97,6 +98,8 @@ Identity-verified means the advertisement was signed by the advertised persisten When a valid secured manifest exists, direct advertisements are returned only if the manifest contains the exact node ID, credential ID, public key, application name, and approved or retiring credential. Those routes are marked `AuthorityApproved`. Unapproved direct peers are filtered out. +After the application has accepted any signed Secured policy, a persistent `secured-policy.lock` marker prevents fallback to identity-only routes when the manifest is missing, corrupt, expired, or temporarily unavailable. The marker contains no secret; file presence is deliberately the security signal so truncated contents remain fail-closed. + Direct discovery can be disabled or tightened: ```csharp @@ -106,7 +109,7 @@ client.AllowIdentityVerifiedPeersWithoutAuthority = false; The peer multicast address, port, advertisement TTL, query interval, and encrypted cache duration are configurable for environments with unusual networking requirements. -## Secured enrollment +## Secured enrollment and live transition For a secured group, first startup works without a manually pasted authority key: @@ -115,9 +118,22 @@ For a secured group, first startup works without a manually pasted authority key 3. MagicControl Web displays the node fingerprint, pairing code, configured group, application schema, and requested capabilities. 4. An enrollment administrator approves that exact credential and nonce. 5. The running application automatically receives the initial signed group manifest, installs the Web authority pin, receives its node-specific settings snapshot, and begins normal refresh. +6. Before publishing the manifest to request-time consumers, the client closes the in-memory access gate and persists the secured-policy latch. +7. The next request and service-resolution call use secured behavior; no process restart is required. Discovery identifies candidates; it does not establish trust. Administrator approval of the proof-bound request establishes the first authority relationship. +Once secured, absence is never interpreted as permission to reopen. The application remains secured through: + +- Web or Mesh outages; +- restarts; +- missing or unreadable ordinary client state; +- missing, corrupt, or expired manifests; +- an empty direct-peer cache; +- discovery of new unmanaged applications. + +Only a successfully validated authority manifest explicitly declaring the group `Open` clears the persistent latch. Opening is ordered fail-safe: the client removes the persistent marker first and only then relaxes the in-memory request gate. A different authority is not silently trusted; moving to another control plane requires an explicit trust-reset and re-enrollment decision rather than connectivity loss being treated as a rebind. + MagicSettings credential rotation preserves the logical node and approval through its continuity proof. A destructive identity reset creates a new node and requires approval again. ## MagicSettings ownership and remote overrides @@ -174,6 +190,14 @@ public IActionResult Status() => Ok(); public IActionResult CreateOrder() => Ok(); ``` +These attributes are dynamic policies: + +- before any signed Secured policy is accepted, they allow normal open application access; +- after secured approval, they immediately require an approved credential and group membership; +- capability attributes additionally require the published capability; +- a known secured policy remains fail-closed when its finite offline lease expires; +- a missing manifest cannot downgrade a persisted secured application. + `IMagicControlAuthorizationService` remains available for manual checks and complete directory lookup. Direct peer discovery never causes these authority-backed checks to downgrade to identity-only authorization. ## Service discovery and routing @@ -200,12 +224,13 @@ Route preference is loopback, LAN, private routed address, then public address. - Web owns approval, settings publication, revocation, and signatures. - Mesh caches signed manifests and approved offline-safe node snapshots. -- Clients keep encrypted last-known-good authorization, permitted settings, and a separate short-lived direct peer cache. -- Existing approved applications continue communicating directly when Web is unavailable. +- Clients keep encrypted last-known-good authorization, permitted settings, a separate short-lived direct peer cache, and a non-secret sticky secured-policy marker. +- Existing approved applications continue communicating directly when Web is unavailable while their authority-signed offline policy permits it. - Existing approved applications can recover cached state through Mesh when Web is unavailable. -- Applications with no platform can still discover identity-verified peers directly. +- Applications that have never been secured can still discover identity-verified peers directly. +- Applications that have been secured never fall back to identity-only routing or open request access during an outage. - New enrollment and live-only secret retrieval require Web. -- A finite group offline lease expires from the authority-signed manifest issue time; reaching a stale Mesh cannot extend it. +- A finite group offline lease expires from the authority-signed manifest issue time; reaching a stale Mesh cannot extend it, and expiry remains fail-closed. - Infinite offline trust remains the default for availability-first installations. ## Deployable components From c826afc957fea86bdc61f8a92e85170f8d240e20 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:15:55 -0400 Subject: [PATCH 32/35] Make security mode transitions crash-safe --- .../MagicControlClientSyncTransport.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs b/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs index 50be38d..44218d4 100644 --- a/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs +++ b/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs @@ -225,10 +225,21 @@ private async ValueTask SynchronizeThroughAsync( validation.Error ?? "MagicControl returned an invalid signed manifest."); } - // Apply security policy before publishing the manifest to request-time consumers. - // Secured closes immediately and persists; Open clears the persistent latch first. - await _securityState.ApplyValidatedManifestAsync(manifest, cancellationToken); - await _manifestStore.SaveAsync(stored, cancellationToken); + if (manifest.Manifest.SecurityMode == MagicControlGroupSecurityMode.Secured) + { + // Securing is close-first: lock memory and persist the latch before the new + // authority state is exposed to request-time consumers. + await _securityState.ApplyValidatedManifestAsync(manifest, cancellationToken); + await _manifestStore.SaveAsync(stored, cancellationToken); + } + else + { + // Opening is persist-first: durably save the validated signed Open manifest + // before removing the latch. A crash can therefore only leave us more secure. + await _manifestStore.SaveAsync(stored, cancellationToken); + await _securityState.ApplyValidatedManifestAsync(manifest, cancellationToken); + } + _manifestCache.Set(new MagicControlManifestState( manifest, DateTimeOffset.UtcNow, From b0862082ab1966267a3f781b028ffdf922f7389a Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:16:28 -0400 Subject: [PATCH 33/35] Make legacy security transitions crash-safe --- .../Client/Runtime/MagicControlClientRuntime.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/MagicControl/Client/Runtime/MagicControlClientRuntime.cs b/MagicControl/Client/Runtime/MagicControlClientRuntime.cs index 07c656a..5e1a0fc 100644 --- a/MagicControl/Client/Runtime/MagicControlClientRuntime.cs +++ b/MagicControl/Client/Runtime/MagicControlClientRuntime.cs @@ -178,6 +178,7 @@ private async ValueTask LoadCachedManifestAsync(CancellationToken cancella return false; } + // The manifest is already durable at this point. Applying Open may safely clear the latch. await securityState.ApplyValidatedManifestAsync(stored.Envelope, cancellationToken); cache.Set(new MagicControlManifestState( stored.Envelope, @@ -248,8 +249,17 @@ private async Task TryLegacyManifestRefreshAsync(CancellationToken cancell validation.Error ?? "The Mesh API returned an invalid group manifest."); } - await securityState.ApplyValidatedManifestAsync(envelope, cancellationToken); - await store.SaveAsync(stored, cancellationToken); + if (envelope.Manifest.SecurityMode == MagicControlGroupSecurityMode.Secured) + { + await securityState.ApplyValidatedManifestAsync(envelope, cancellationToken); + await store.SaveAsync(stored, cancellationToken); + } + else + { + await store.SaveAsync(stored, cancellationToken); + await securityState.ApplyValidatedManifestAsync(envelope, cancellationToken); + } + cache.Set(new MagicControlManifestState(envelope, now, LoadedFromDisk: false)); status.RecordSuccess(endpoint, now); return true; From d4623cd5148c8bc6cfb8db3996013fc71bb7dc71 Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:17:48 -0400 Subject: [PATCH 34/35] Correct crash-safe open transition documentation --- docs/client-platform.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/client-platform.md b/docs/client-platform.md index cf1a4d0..8149987 100644 --- a/docs/client-platform.md +++ b/docs/client-platform.md @@ -132,7 +132,7 @@ Once secured, absence is never interpreted as permission to reopen. The applicat - an empty direct-peer cache; - discovery of new unmanaged applications. -Only a successfully validated authority manifest explicitly declaring the group `Open` clears the persistent latch. Opening is ordered fail-safe: the client removes the persistent marker first and only then relaxes the in-memory request gate. A different authority is not silently trusted; moving to another control plane requires an explicit trust-reset and re-enrollment decision rather than connectivity loss being treated as a rebind. +Only a successfully validated authority manifest explicitly declaring the group `Open` clears the persistent latch. Opening is ordered fail-safe: the client first durably saves the validated signed Open manifest, then removes the persistent marker, and only then relaxes the in-memory request gate. A crash can therefore leave the application more restrictive, never accidentally open. A different authority is not silently trusted; moving to another control plane requires an explicit trust-reset and re-enrollment decision rather than connectivity loss being treated as a rebind. MagicSettings credential rotation preserves the logical node and approval through its continuity proof. A destructive identity reset creates a new node and requires approval again. From 916cab2e1b66499e32b04d09f6f740729f61bc7f Mon Sep 17 00:00:00 2001 From: Magic <131926685+magiccodingman@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:18:56 -0400 Subject: [PATCH 35/35] Summarize sticky runtime security behavior --- README.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 449ad72..b1c713e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ MagicControl is a lightweight application control plane for managing users, appl ## MagicControl.Client -`MagicControl.Client` is the application-side NuGet package. It initializes MagicSettings, maintains the application's MagicControl identity, discovers ordinary applications directly, refreshes signed group manifests when a control plane is available, authorizes peer requests from local cached state, and resolves service instances without placing Mesh in the application request path. +`MagicControl.Client` is the application-side NuGet package. It initializes MagicSettings, maintains the application's MagicControl identity, discovers ordinary applications directly on the LAN, refreshes signed group manifests in the background, authorizes peer requests from local cached state, and resolves known service instances without placing the Mesh API in the request path. Install the package: @@ -19,15 +19,14 @@ var magicControl = await builder.AddMagicControlClientAsync { + settings.ApplicationId = "Orders"; settings.Template = new MyApplicationSettings(); }, configureClient: client => { client.GroupId = Guid.Parse("00000000-0000-0000-0000-000000000000"); client.ApplicationName = "Orders"; - client.AdvertiseEndpoint( - "https://orders.internal.example:7443", - isLan: true); + client.AdvertiseEndpoint("https://orders.local:7443", isLan: true); }); if (magicControl.ShouldExit) @@ -36,13 +35,13 @@ if (magicControl.ShouldExit) } ``` -`GroupId` and `ApplicationName` are intentionally configured. Mesh endpoints are discovered automatically on ordinary LANs; `AddMeshEndpointOverride(...)` is available only when a routed or multicast-restricted environment needs an explicit seed. +A Mesh URL is optional. Before an application has accepted a signed Secured policy, MagicControl-protected endpoints remain open and direct LAN peers are available as identity-verified routes. Approval can switch the running application to secured behavior without a restart. -Even with no MagicControl Web deployment, no Mesh API, and no cached signed directory, applications can discover one another through identity-signed direct peer advertisements. Resolver results expose whether a route is merely `IdentityVerified` or is `AuthorityApproved` by a secured cached manifest. +Once secured, the client writes a non-secret sticky security marker. Outages, missing manifests, expired leases, restarts, or corrupt ordinary cache files cannot reopen the application or restore identity-only routing. Only a successfully validated authority manifest explicitly publishing `Open` may clear that latch. -Applications can protect ASP.NET Core endpoints with `[RequireMagicControlMember]` or `[RequireMagicControlCapability("capability.name")]`. Direct discovery never grants these authority-backed permissions. `IMagicControlAuthorizationService` is available for manual authorization, and `IMagicControlServiceResolver` handles signed-directory and direct-peer route selection. +Applications can protect ASP.NET Core endpoints with `[RequireMagicControlMember]` or `[RequireMagicControlCapability("capability.name")]`. `IMagicControlAuthorizationService` is available for manual authorization, and `IMagicControlServiceResolver` combines signed directory entries with directly discovered application peers. -Offline trust is infinite by default. A secured group may instead configure a finite offline trust period from the MagicControl Web control pane. +Offline trust is infinite by default. A secured group may instead configure a finite offline trust period from the MagicControl Web control pane; expiration remains fail-closed. ## Current foundation @@ -52,12 +51,13 @@ Offline trust is infinite by default. A secured group may instead configure a fi - Cookie authentication, forced password changes, and local-only administrator recovery. - User, role, enrollment, managed-instance, and group-policy administration. - Signed application and Mesh API enrollment using MagicSettings node identities. -- Direct identity-signed application discovery without Web or Mesh. +- Automatic Mesh discovery plus direct application-to-application LAN discovery. - Signed multi-group manifests and encrypted last-known-good caches. +- Sticky open-to-secured runtime transitions that never downgrade during outages. - Open directory discovery and secured-only distributed settings. - Local cached peer authentication and capability authorization. - Audit records and health checks. -MagicControl Web remains the durable control-plane authority. Mesh distributes and caches signed state but is not a required application traffic proxy or a prerequisite for ordinary direct LAN discovery. +MagicControl Web remains the durable control-plane authority. The Mesh API distributes and caches signed state but is not a required application traffic proxy. See [`docs/client-platform.md`](docs/client-platform.md), [`docs/foundation.md`](docs/foundation.md), and [`docs/mesh-architecture.md`](docs/mesh-architecture.md) for setup, security, and architecture details.