diff --git a/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs b/MagicControl/Client/Authentication/MagicControlNodeAuthentication.cs index 898ee13..369e4b4 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,61 @@ public RequireMagicControlCapabilityAttribute(string capability) } } -public sealed record MagicControlCapabilityRequirement(string Capability) : IAuthorizationRequirement; - -public sealed class MagicControlCapabilityHandler - : AuthorizationHandler +/// +/// 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( + MagicControlManifestCache cache, + MagicControlRuntimeSecurityState securityState) + : AuthorizationHandler { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, - MagicControlCapabilityRequirement requirement) + MagicControlAccessRequirement requirement) { - if (context.User.HasClaim( + var states = cache.GetAll(); + var securedStates = states + .Where(state => state.Manifest.SecurityMode == MagicControlGroupSecurityMode.Secured) + .ToArray(); + + // 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; + } + + 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, + state.Manifest.GroupId.ToString("D"))); + if (!belongsToUsableSecuredGroup) + { + 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 +218,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 +234,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 +242,10 @@ public Task GetDefaultPolicyAsync() public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync(); + + private static AuthorizationPolicy BuildPolicy(string? capability) + => new AuthorizationPolicyBuilder( + MagicControlMeshProtocol.NodeAuthenticationScheme) + .AddRequirements(new MagicControlAccessRequirement(capability)) + .Build(); } diff --git a/MagicControl/Client/Configuration/MagicControlClientExtensions.cs b/MagicControl/Client/Configuration/MagicControlClientExtensions.cs index 9bba1b9..16b5ed5 100644 --- a/MagicControl/Client/Configuration/MagicControlClientExtensions.cs +++ b/MagicControl/Client/Configuration/MagicControlClientExtensions.cs @@ -32,6 +32,10 @@ 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 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); @@ -48,7 +52,8 @@ public static async ValueTask AddMagicControl manifestStore, validator, cache, - status); + status, + securityState); var logicalEndpointResolver = new MagicControlLogicalEndpointResolver( clientOptions, contextHash); @@ -86,6 +91,10 @@ public static async ValueTask AddMagicControl cache, manifestStore, clientStateStore, + peerDirectory, + peerDirectoryStore, + securityLatchStore, + securityState, validator, endpointResolver, status, @@ -107,6 +116,10 @@ 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 securityLatchStore = new FileMagicControlSecurityLatchStore(options); + var securityState = new MagicControlRuntimeSecurityState(securityLatchStore); var validator = new MagicControlManifestValidator(options); var status = new MagicControlClientStatus(); var resolver = new DiscoveringMagicControlMeshEndpointResolver(options, stateStore); @@ -116,6 +129,10 @@ public static IServiceCollection AddMagicControlClient( services.AddSingleton(cache); services.AddSingleton(manifestStore); services.AddSingleton(stateStore); + services.AddSingleton(peerDirectory); + services.AddSingleton(peerDirectoryStore); + services.AddSingleton(securityLatchStore); + services.AddSingleton(securityState); services.AddSingleton(validator); services.AddSingleton(resolver); services.AddSingleton(status); @@ -124,6 +141,7 @@ public static IServiceCollection AddMagicControlClient( services.AddHttpClient(MagicControlHttpClients.Mesh) .AddMagicNodeAuthentication(MagicControlMeshProtocol.MeshPeerAudience); services.AddHostedService(); + services.AddHostedService(); return services; } @@ -135,6 +153,7 @@ public static IServiceCollection AddMagicControlNodeAuthorization( services.TryAddSingleton(); services.TryAddSingleton(provider => provider.GetRequiredService()); + services.TryAddSingleton(_ => new MagicControlRuntimeSecurityState()); services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); @@ -148,7 +167,7 @@ public static IServiceCollection AddMagicControlNodeAuthorization( _ => { }); services.AddAuthorization(); services.TryAddEnumerable( - ServiceDescriptor.Singleton()); + ServiceDescriptor.Singleton()); services.Replace(ServiceDescriptor.Singleton()); @@ -161,6 +180,10 @@ private static void RegisterClientServices( MagicControlManifestCache cache, FileMagicControlManifestStore manifestStore, FileMagicControlClientStateStore stateStore, + MagicControlPeerDirectory peerDirectory, + FileMagicControlPeerDirectoryStore peerDirectoryStore, + FileMagicControlSecurityLatchStore securityLatchStore, + MagicControlRuntimeSecurityState securityState, MagicControlManifestValidator validator, DiscoveringMagicControlMeshEndpointResolver endpointResolver, MagicControlClientStatus status, @@ -171,6 +194,10 @@ private static void RegisterClientServices( services.AddSingleton(cache); services.AddSingleton(manifestStore); services.AddSingleton(stateStore); + services.AddSingleton(peerDirectory); + services.AddSingleton(peerDirectoryStore); + services.AddSingleton(securityLatchStore); + services.AddSingleton(securityState); services.AddSingleton(validator); services.AddSingleton(endpointResolver); services.AddSingleton(status); @@ -181,5 +208,6 @@ private static void RegisterClientServices( services.AddHttpClient(MagicControlHttpClients.Mesh) .AddMagicNodeAuthentication(MagicControlMeshProtocol.MeshPeerAudience); services.AddHostedService(); + services.AddHostedService(); } } diff --git a/MagicControl/Client/Configuration/MagicControlClientOptions.cs b/MagicControl/Client/Configuration/MagicControlClientOptions.cs index b206446..c612117 100644 --- a/MagicControl/Client/Configuration/MagicControlClientOptions.cs +++ b/MagicControl/Client/Configuration/MagicControlClientOptions.cs @@ -31,6 +31,14 @@ 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"; + + /// + /// 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; @@ -41,6 +49,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 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; + public bool AllowInsecureHttp { get; set; } public List MeshEndpointSeeds { get; } = []; @@ -109,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(); @@ -136,6 +169,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); diff --git a/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs b/MagicControl/Client/ControlPlane/MagicControlClientSyncTransport.cs index c04688e..44218d4 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,7 +225,21 @@ private async ValueTask SynchronizeThroughAsync( validation.Error ?? "MagicControl returned an invalid signed manifest."); } - 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, @@ -303,6 +320,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 +335,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, 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); +} diff --git a/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs b/MagicControl/Client/Discovery/MagicControlPeerDirectory.cs new file mode 100644 index 0000000..cc08c69 --- /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); + + internal 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(); + } + } + + internal 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; + } + } +} diff --git a/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs b/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs new file mode 100644 index 0000000..eb45ae5 --- /dev/null +++ b/MagicControl/Client/Discovery/MagicControlPeerDiscoveryService.cs @@ -0,0 +1,324 @@ +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +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(300, 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); +} diff --git a/MagicControl/Client/Discovery/MagicControlServiceResolver.cs b/MagicControl/Client/Discovery/MagicControlServiceResolver.cs index 9a86e30..511b352 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,91 @@ 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 MagicControlRuntimeSecurityState _securityState; 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), + new MagicControlRuntimeSecurityState()) + { + } + + 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( 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 knownManifest = state?.Manifest; + 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 + && (usableManifest.SecurityMode == MagicControlGroupSecurityMode.Secured + || !_securityState.RequiresAuthorization)) { - return []; + all.AddRange(FromSignedDirectory(usableManifest, applicationName, now)); + all.AddRange(FromDirectPeers(usableManifest, applicationName, now)); + } + else if (!securedPolicyKnown + && _options.AllowIdentityVerifiedPeersWithoutAuthority) + { + all.AddRange(FromDirectPeers(null, 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) + 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 +144,7 @@ public IReadOnlyList ResolveAll( return null; } - if (options.RouteSelection != MagicControlRouteSelectionMode.RoundRobin) + if (_options.RouteSelection != MagicControlRouteSelectionMode.RoundRobin) { return candidates[0]; } @@ -113,6 +177,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.FirstOrDefault(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 +305,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 +327,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; + } + } } diff --git a/MagicControl/Client/Runtime/MagicControlClientRuntime.cs b/MagicControl/Client/Runtime/MagicControlClientRuntime.cs index 5944c62..5e1a0fc 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,12 @@ 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; } + // 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, stored.LastAuthorityContactUtc, @@ -244,7 +249,17 @@ private async Task TryLegacyManifestRefreshAsync(CancellationToken cancell validation.Error ?? "The Mesh API returned an invalid group manifest."); } - 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; 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(); + } +} diff --git a/MagicControl/Client/State/MagicControlSecurityLatch.cs b/MagicControl/Client/State/MagicControlSecurityLatch.cs new file mode 100644 index 0000000..b438387 --- /dev/null +++ b/MagicControl/Client/State/MagicControlSecurityLatch.cs @@ -0,0 +1,181 @@ +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 +{ + 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; + + /// + /// 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); + } + + 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; + } + } +} 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; } 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); diff --git a/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs new file mode 100644 index 0000000..b4dfe68 --- /dev/null +++ b/MagicControl/Tests/MagicControl.Tests/PeerDiscoveryTests.cs @@ -0,0 +1,287 @@ +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 member = CreateApprovedMember(signed.Advertisement); + 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 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() + { + var statePath = Path.Combine( + Path.GetTempPath(), + "magic-control-peer-cache", + Guid.NewGuid().ToString("N")); + var options = CreateOptions(); + options.StatePath = 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( + "192.168.10.25", + Encoding.UTF8.GetString(bytes), + StringComparison.Ordinal); + + 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 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, + DateTimeOffset? issuedUtc = null, + MagicControlOfflineTrustPolicy? offlineTrust = null) + { + var issued = issuedUtc ?? DateTimeOffset.UtcNow; + var manifest = new MagicControlGroupManifest( + groupId, + "Home", + MagicControlGroupSecurityMode.Secured, + Guid.NewGuid(), + 1, + issued, + offlineTrust ?? MagicControlOfflineTrustPolicy.Infinite, + members, + [], + MagicControlSettingsSnapshot.Empty(issued)); + using var authority = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var cache = new MagicControlManifestCache(); + cache.Set(new MagicControlManifestState( + MagicControlManifestCryptography.Sign(manifest, authority), + issued, + 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); +} 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); +} diff --git a/README.md b/README.md index e13b259..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, 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 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: @@ -26,7 +26,7 @@ var magicControl = await builder.AddMagicControlClientAsync Ok(); public IActionResult CreateOrder() => Ok(); ``` -`IMagicControlAuthorizationService` remains available for manual checks and complete directory lookup. +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 -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. -- 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 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 - **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. 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.