Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2725751
Add direct peer discovery contracts
magiccodingman Jul 20, 2026
a2ea462
Add peer discovery protocol constants
magiccodingman Jul 20, 2026
5deda9c
Configure direct peer discovery
magiccodingman Jul 20, 2026
fe55206
Verify signed peer advertisements
magiccodingman Jul 20, 2026
2fa054e
Cache verified direct peers
magiccodingman Jul 20, 2026
9348a9f
Persist verified peer directory
magiccodingman Jul 20, 2026
88e1ae5
Discover applications directly over LAN
magiccodingman Jul 20, 2026
e454031
Resolve direct peers without Mesh
magiccodingman Jul 20, 2026
ef88888
Register direct peer discovery services
magiccodingman Jul 20, 2026
143467c
Test direct peer discovery trust boundaries
magiccodingman Jul 20, 2026
234a5fd
Fix peer discovery test setup
magiccodingman Jul 20, 2026
ac7b29a
Document standalone peer discovery
magiccodingman Jul 20, 2026
fec87a0
Harden peer discovery runtime
magiccodingman Jul 20, 2026
77fbf8b
Document automatic application discovery
magiccodingman Jul 20, 2026
b07bf23
Restrict peer directory mutation
magiccodingman Jul 20, 2026
5ae9ca4
Correct peer cache test setup
magiccodingman Jul 20, 2026
637cae5
Document direct peer trust model
magiccodingman Jul 20, 2026
c383421
Prevent secured discovery downgrade
magiccodingman Jul 20, 2026
f1ab03c
Test expired secured policy remains fail closed
magiccodingman Jul 20, 2026
cb6f299
Make MagicControl authorization dynamically secure
magiccodingman Jul 20, 2026
db57157
Persist sticky secured application state
magiccodingman Jul 20, 2026
8dda9c3
Configure persistent secured policy latch
magiccodingman Jul 20, 2026
d0060af
Wire sticky runtime security state
magiccodingman Jul 20, 2026
0203fe1
Apply validated security policy during sync
magiccodingman Jul 20, 2026
e239ec7
Keep secured state across startup and outages
magiccodingman Jul 20, 2026
224b7c0
Honor sticky secured policy in request authorization
magiccodingman Jul 20, 2026
71f3848
Support in-memory security state for resolver tests
magiccodingman Jul 20, 2026
d11effb
Prevent secured clients from downgrading peer resolution
magiccodingman Jul 20, 2026
231557a
Test sticky open to secured runtime transitions
magiccodingman Jul 20, 2026
c697330
Preserve standalone authorization registration compatibility
magiccodingman Jul 20, 2026
b8df3c6
Document sticky open to secured transition
magiccodingman Jul 20, 2026
c826afc
Make security mode transitions crash-safe
magiccodingman Jul 20, 2026
b086208
Make legacy security transitions crash-safe
magiccodingman Jul 20, 2026
d4623cd
Correct crash-safe open transition documentation
magiccodingman Jul 20, 2026
916cab2
Summarize sticky runtime security behavior
magiccodingman Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,18 @@ private async ValueTask<string> 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;
}
}

Expand All @@ -145,22 +151,61 @@ public RequireMagicControlCapabilityAttribute(string capability)
}
}

public sealed record MagicControlCapabilityRequirement(string Capability) : IAuthorizationRequirement;

public sealed class MagicControlCapabilityHandler
: AuthorizationHandler<MagicControlCapabilityRequirement>
/// <summary>
/// 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.
/// </summary>
public sealed record MagicControlAccessRequirement(string? Capability) : IAuthorizationRequirement;

public sealed class MagicControlAccessHandler(
MagicControlManifestCache cache,
MagicControlRuntimeSecurityState securityState)
: AuthorizationHandler<MagicControlAccessRequirement>
{
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;
}
}
Expand All @@ -173,6 +218,14 @@ public sealed class MagicControlCapabilityPolicyProvider(

public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (string.Equals(
policyName,
MagicControlAuthorizationPolicies.Member,
StringComparison.Ordinal))
{
return Task.FromResult<AuthorizationPolicy?>(BuildPolicy(capability: null));
}

if (!policyName.StartsWith(
MagicControlMeshProtocol.CapabilityPolicyPrefix,
StringComparison.Ordinal))
Expand All @@ -181,18 +234,18 @@ 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<AuthorizationPolicy?>(policy);
return Task.FromResult<AuthorizationPolicy?>(BuildPolicy(capability));
}

public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
=> _fallback.GetDefaultPolicyAsync();

public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> _fallback.GetFallbackPolicyAsync();

private static AuthorizationPolicy BuildPolicy(string? capability)
=> new AuthorizationPolicyBuilder(
MagicControlMeshProtocol.NodeAuthenticationScheme)
.AddRequirements(new MagicControlAccessRequirement(capability))
.Build();
}
32 changes: 30 additions & 2 deletions MagicControl/Client/Configuration/MagicControlClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ public static async ValueTask<MagicSettingsInitializationResult> 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);
Expand All @@ -48,7 +52,8 @@ public static async ValueTask<MagicSettingsInitializationResult> AddMagicControl
manifestStore,
validator,
cache,
status);
status,
securityState);
var logicalEndpointResolver = new MagicControlLogicalEndpointResolver(
clientOptions,
contextHash);
Expand Down Expand Up @@ -86,6 +91,10 @@ public static async ValueTask<MagicSettingsInitializationResult> AddMagicControl
cache,
manifestStore,
clientStateStore,
peerDirectory,
peerDirectoryStore,
securityLatchStore,
securityState,
validator,
endpointResolver,
status,
Expand All @@ -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);
Expand All @@ -116,6 +129,10 @@ public static IServiceCollection AddMagicControlClient(
services.AddSingleton<IMagicControlManifestSource>(cache);
services.AddSingleton<IMagicControlManifestStore>(manifestStore);
services.AddSingleton<IMagicControlClientStateStore>(stateStore);
services.AddSingleton(peerDirectory);
services.AddSingleton<IMagicControlPeerDirectoryStore>(peerDirectoryStore);
services.AddSingleton<IMagicControlSecurityLatchStore>(securityLatchStore);
services.AddSingleton(securityState);
services.AddSingleton(validator);
services.AddSingleton<IMagicControlMeshEndpointResolver>(resolver);
services.AddSingleton(status);
Expand All @@ -124,6 +141,7 @@ public static IServiceCollection AddMagicControlClient(
services.AddHttpClient(MagicControlHttpClients.Mesh)
.AddMagicNodeAuthentication(MagicControlMeshProtocol.MeshPeerAudience);
services.AddHostedService<MagicControlClientHostedService>();
services.AddHostedService<MagicControlPeerDiscoveryService>();
return services;
}

Expand All @@ -135,6 +153,7 @@ public static IServiceCollection AddMagicControlNodeAuthorization(
services.TryAddSingleton<MagicControlManifestCache>();
services.TryAddSingleton<IMagicControlManifestSource>(provider =>
provider.GetRequiredService<MagicControlManifestCache>());
services.TryAddSingleton(_ => new MagicControlRuntimeSecurityState());
services.TryAddSingleton<IMagicControlAuthorizationService, MagicControlAuthorizationService>();
services.TryAddSingleton<MagicControlCachedCredentialRegistry>();
services.TryAddSingleton<InMemoryMagicReplayCache>();
Expand All @@ -148,7 +167,7 @@ public static IServiceCollection AddMagicControlNodeAuthorization(
_ => { });
services.AddAuthorization();
services.TryAddEnumerable(
ServiceDescriptor.Singleton<IAuthorizationHandler, MagicControlCapabilityHandler>());
ServiceDescriptor.Singleton<IAuthorizationHandler, MagicControlAccessHandler>());
services.Replace(ServiceDescriptor.Singleton<IAuthorizationPolicyProvider,
MagicControlCapabilityPolicyProvider>());

Expand All @@ -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,
Expand All @@ -171,6 +194,10 @@ private static void RegisterClientServices(
services.AddSingleton<IMagicControlManifestSource>(cache);
services.AddSingleton<IMagicControlManifestStore>(manifestStore);
services.AddSingleton<IMagicControlClientStateStore>(stateStore);
services.AddSingleton(peerDirectory);
services.AddSingleton<IMagicControlPeerDirectoryStore>(peerDirectoryStore);
services.AddSingleton<IMagicControlSecurityLatchStore>(securityLatchStore);
services.AddSingleton(securityState);
services.AddSingleton(validator);
services.AddSingleton<IMagicControlMeshEndpointResolver>(endpointResolver);
services.AddSingleton(status);
Expand All @@ -181,5 +208,6 @@ private static void RegisterClientServices(
services.AddHttpClient(MagicControlHttpClients.Mesh)
.AddMagicNodeAuthentication(MagicControlMeshProtocol.MeshPeerAudience);
services.AddHostedService<MagicControlClientHostedService>();
services.AddHostedService<MagicControlPeerDiscoveryService>();
}
}
54 changes: 54 additions & 0 deletions MagicControl/Client/Configuration/MagicControlClientOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>
/// 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.
/// </summary>
public string SecurityLatchFileName { get; set; } = "secured-policy.lock";

public MagicControlStartupMode StartupMode { get; set; } = MagicControlStartupMode.CachedFirst;
public MagicControlRouteSelectionMode RouteSelection { get; set; } = MagicControlRouteSelectionMode.Automatic;
Expand All @@ -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;

/// <summary>
/// Enables application-to-application LAN discovery inside MagicControl.Client. This path
/// works without MagicControl Web, a Mesh API, or a cached authority directory.
/// </summary>
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);

/// <summary>
/// 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.
/// </summary>
public bool AllowIdentityVerifiedPeersWithoutAuthority { get; set; } = true;

public bool AllowInsecureHttp { get; set; }

public List<Uri> MeshEndpointSeeds { get; } = [];
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -27,7 +28,8 @@ public MagicControlClientSyncTransport(
IMagicControlManifestStore manifestStore,
MagicControlManifestValidator manifestValidator,
MagicControlManifestCache manifestCache,
MagicControlClientStatus status)
MagicControlClientStatus status,
MagicControlRuntimeSecurityState securityState)
{
_options = options;
_endpointResolver = endpointResolver;
Expand All @@ -36,6 +38,7 @@ public MagicControlClientSyncTransport(
_manifestValidator = manifestValidator;
_manifestCache = manifestCache;
_status = status;
_securityState = securityState;

_httpClient = new HttpClient(new SocketsHttpHandler
{
Expand Down Expand Up @@ -222,7 +225,21 @@ private async ValueTask<MagicSettingsSyncResponse> 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,
Expand Down Expand Up @@ -303,6 +320,7 @@ private async ValueTask<MagicSettingsSyncResponse> CreateOfflineResponseAsync(
cancellationToken);
if (validation.IsValid)
{
await _securityState.ApplyValidatedManifestAsync(stored.Envelope, cancellationToken);
_manifestCache.Set(new MagicControlManifestState(
stored.Envelope,
stored.LastAuthorityContactUtc,
Expand All @@ -317,6 +335,7 @@ private async ValueTask<MagicSettingsSyncResponse> 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,
Expand Down
Loading