diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..40604d7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + build-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + - name: Restore + working-directory: MagicSettings + run: dotnet restore MagicSettings.sln + - name: Build + id: build + working-directory: MagicSettings + shell: bash + run: | + set +e + dotnet build MagicSettings.sln --configuration Release --no-restore --consoleLoggerParameters:ErrorsOnly > ../build.log 2>&1 + exit_code=$? + grep -E "(^|[[:space:]])(error|warning) (CS|NU|NETSDK|MSB)[0-9]+|Build FAILED|Build succeeded" ../build.log > ../build-summary.log || true + cat ../build-summary.log + exit $exit_code + - name: Upload build diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: build-diagnostics + path: | + build.log + build-summary.log + if-no-files-found: error + - name: Test + working-directory: MagicSettings + run: dotnet test MagicSettings.sln --configuration Release --no-build --logger "console;verbosity=normal" diff --git a/.gitignore b/.gitignore index e2f136b..6840df3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,17 @@ -## Build output [Bb]in/ [Oo]bj/ artifacts/ -*.user -*.suo +TestResults/ +coverage/ .idea/ .vs/ - -## Runtime state and secrets -**/.magic-ai/ -**/state/ +*.user +*.suo *.pfx *.pem *.key *.password -appsettings.Local.json .env - -## Test output -TestResults/ -coverage/ +**/.magicsettings.identity.json +**/.magicsettings.identity.json.* +appsettings.Local.json diff --git a/MagicSettings/Directory.Build.props b/MagicSettings/Directory.Build.props new file mode 100644 index 0000000..4bd9c1a --- /dev/null +++ b/MagicSettings/Directory.Build.props @@ -0,0 +1,12 @@ + + + net10.0 + latest + enable + enable + true + true + $(NoWarn);1591 + true + + diff --git a/MagicSettings/MagicSettings.Server/MagicSettings.Server.csproj b/MagicSettings/MagicSettings.Server/MagicSettings.Server.csproj index 237d661..a46af5e 100644 --- a/MagicSettings/MagicSettings.Server/MagicSettings.Server.csproj +++ b/MagicSettings/MagicSettings.Server/MagicSettings.Server.csproj @@ -1,9 +1,10 @@ - - - - net10.0 - enable - enable - - + + + MagicSettings.Server + Storage-agnostic server helpers for MagicSettings enrollment, synchronization, authorization, and proof verification. + + + + + diff --git a/MagicSettings/MagicSettings.Server/Source.01.cs b/MagicSettings/MagicSettings.Server/Source.01.cs new file mode 100644 index 0000000..e896af6 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Source.01.cs @@ -0,0 +1,436 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server +{ +public static class MagicAspNetProofVerificationExtensions +{ + public static async ValueTask VerifyHttpRequestAsync( + this MagicNodeProofVerifier verifier, + HttpRequest request, + string expectedAudience, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(verifier); + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(expectedAudience); + + if (!request.Headers.TryGetValue("Authorization", out var authorization)) + { + return MagicProofVerificationResult.Invalid("The MagicNode authorization header is missing."); + } + + var header = authorization.ToString(); + const string prefix = "MagicNode "; + if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return MagicProofVerificationResult.Invalid("The authorization scheme is not MagicNode."); + } + + MagicAuthenticationProof proof; + try + { + proof = MagicNodeProofCodec.Decode(header[prefix.Length..].Trim()); + } + catch (Exception exception) when (exception is FormatException or System.Text.Json.JsonException) + { + return MagicProofVerificationResult.Invalid("The MagicNode authorization proof is malformed."); + } + + request.EnableBuffering(); + string bodyHash; + if (request.ContentLength is null or 0) + { + bodyHash = Convert.ToHexString(SHA256.HashData(Array.Empty())).ToLowerInvariant(); + } + else + { + await using var buffer = new MemoryStream(); + await request.Body.CopyToAsync(buffer, cancellationToken); + request.Body.Position = 0; + bodyHash = Convert.ToHexString(SHA256.HashData(buffer.ToArray())).ToLowerInvariant(); + } + + var uri = new Uri($"{request.Scheme}://{request.Host}{request.PathBase}{request.Path}{request.QueryString}"); + return await verifier.VerifyAsync( + new(proof, expectedAudience, request.Method, uri, bodyHash, DateTimeOffset.UtcNow), + cancellationToken); + } +} +} +namespace MagicSettings.Server +{ +/// +/// Storage-agnostic credential lifecycle helpers for control-plane implementations. +/// +public sealed class MagicCredentialAdministrationService +{ + private readonly IMagicCredentialRegistry _credentials; + + public MagicCredentialAdministrationService(IMagicCredentialRegistry credentials) => _credentials = credentials; + + public async ValueTask SetStatusAsync( + Guid nodeId, + Guid credentialId, + MagicCredentialStatus status, + CancellationToken cancellationToken = default) + { + var credential = await _credentials.FindAsync(nodeId, credentialId, cancellationToken); + if (credential is null) + { + return false; + } + + await _credentials.UpsertAsync(credential with + { + Status = status, + UpdatedUtc = DateTimeOffset.UtcNow + }, cancellationToken); + return true; + } + + public ValueTask ApproveAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) + => SetStatusAsync(nodeId, credentialId, MagicCredentialStatus.Approved, cancellationToken); + + public ValueTask RevokeAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) + => SetStatusAsync(nodeId, credentialId, MagicCredentialStatus.Revoked, cancellationToken); +} +} +namespace MagicSettings.Server +{ +public sealed class MagicCredentialRotationService +{ + private readonly IMagicCredentialRegistry _credentials; + + public MagicCredentialRotationService(IMagicCredentialRegistry credentials) => _credentials = credentials; + + public async ValueTask ApplyAsync( + MagicIdentityContinuityProof continuity, + bool autoApproveNewCredential, + CancellationToken cancellationToken = default) + { + if (!MagicNodeProofVerifier.VerifyContinuity(continuity)) + { + return MagicProofVerificationResult.Invalid("The identity continuity proof is invalid."); + } + + var previous = await _credentials.FindAsync( + continuity.PreviousIdentity.NodeId, + continuity.PreviousIdentity.CredentialId, + cancellationToken); + if (previous is null) + { + return MagicProofVerificationResult.Invalid("The previous credential is unknown."); + } + + if (previous.Status is not (MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring)) + { + return MagicProofVerificationResult.Invalid("The previous credential is not authorized to rotate."); + } + + if (!string.Equals(previous.PublicKey, continuity.PreviousIdentity.PublicKey, StringComparison.Ordinal)) + { + return MagicProofVerificationResult.Invalid("The continuity proof does not match the registered previous key."); + } + + await _credentials.UpsertAsync(previous with + { + Status = MagicCredentialStatus.Retiring, + UpdatedUtc = DateTimeOffset.UtcNow + }, cancellationToken); + + await _credentials.UpsertAsync(new( + continuity.NewIdentity.NodeId, + continuity.NewIdentity.CredentialId, + continuity.NewIdentity.PublicKey, + autoApproveNewCredential ? MagicCredentialStatus.Approved : MagicCredentialStatus.Pending, + DateTimeOffset.UtcNow), cancellationToken); + + return MagicProofVerificationResult.Valid; + } +} +} +namespace MagicSettings.Server +{ +public sealed class InMemoryMagicCredentialRegistry : IMagicCredentialRegistry +{ + private readonly ConcurrentDictionary<(Guid NodeId, Guid CredentialId), MagicRegisteredCredential> _credentials = new(); + + public ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default) + => ValueTask.FromResult(_credentials.TryGetValue((nodeId, credentialId), out var credential) ? credential : null); + + public ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default) + { + _credentials[(credential.NodeId, credential.CredentialId)] = credential; + return ValueTask.CompletedTask; + } +} + +public sealed class InMemoryMagicReplayCache : IMagicReplayCache +{ + private readonly ConcurrentDictionary _nonces = new(StringComparer.Ordinal); + + public ValueTask TryUseAsync(Guid credentialId, string nonce, DateTimeOffset expiresUtc, CancellationToken cancellationToken = default) + { + var now = DateTimeOffset.UtcNow; + foreach (var pair in _nonces) + { + if (pair.Value <= now) + { + _nonces.TryRemove(pair.Key, out _); + } + } + + return ValueTask.FromResult(_nonces.TryAdd($"{credentialId:D}:{nonce}", expiresUtc)); + } +} + +public sealed class InMemoryMagicNodeRemoteRecordStore : IMagicNodeRemoteRecordStore +{ + private readonly ConcurrentDictionary<(Guid NodeId, string ApplicationId), MagicNodeRemoteRecord> _records = new(); + + public ValueTask GetAsync(Guid nodeId, string applicationId, CancellationToken cancellationToken = default) + => ValueTask.FromResult(_records.TryGetValue((nodeId, applicationId), out var record) ? record : null); + + public ValueTask SaveAsync(MagicNodeRemoteRecord record, CancellationToken cancellationToken = default) + { + _records[(record.NodeId, record.ApplicationId)] = record; + return ValueTask.CompletedTask; + } +} +} +namespace MagicSettings.Server +{ +public sealed class MagicNodeProofVerifier +{ + private readonly IMagicCredentialRegistry _credentials; + private readonly IMagicReplayCache _replayCache; + private readonly TimeSpan _allowedClockSkew; + + public MagicNodeProofVerifier(IMagicCredentialRegistry credentials, IMagicReplayCache replayCache, TimeSpan? allowedClockSkew = null) + { + _credentials = credentials; + _replayCache = replayCache; + _allowedClockSkew = allowedClockSkew ?? TimeSpan.FromSeconds(30); + } + + public async ValueTask VerifyAsync(MagicProofVerificationRequest request, CancellationToken cancellationToken = default) + { + var structural = ValidateRequest(request); + if (!structural.IsValid) + { + return structural; + } + + var proof = request.Proof; + var credential = await _credentials.FindAsync(proof.NodeId, proof.CredentialId, cancellationToken); + if (credential is null) + { + return MagicProofVerificationResult.Invalid("The credential is unknown."); + } + + if (credential.Status is not (MagicCredentialStatus.Approved or MagicCredentialStatus.Retiring)) + { + return MagicProofVerificationResult.Invalid($"The credential is {credential.Status}."); + } + + return await VerifySignatureAndReplayAsync(credential.PublicKey, request, cancellationToken); + } + + public async ValueTask VerifyEnrollmentAsync( + MagicNodeIdentityDescriptor identity, + MagicProofVerificationRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identity); + var structural = ValidateRequest(request); + if (!structural.IsValid) + { + return structural; + } + + if (request.Proof.NodeId != identity.NodeId || request.Proof.CredentialId != identity.CredentialId) + { + return MagicProofVerificationResult.Invalid("The proof does not match the supplied node identity."); + } + + if (identity.CredentialKind != MagicCredentialKind.EcdsaP256 + || !string.Equals(identity.SignatureAlgorithm, "ECDSA_P256_SHA256_P1363", StringComparison.Ordinal)) + { + return MagicProofVerificationResult.Invalid("The supplied credential algorithm is unsupported."); + } + + string fingerprint; + try + { + fingerprint = Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(identity.PublicKey))).ToLowerInvariant(); + } + catch (FormatException) + { + return MagicProofVerificationResult.Invalid("The supplied public key is malformed."); + } + + if (!CryptographicOperations.FixedTimeEquals( + Encoding.ASCII.GetBytes(fingerprint), + Encoding.ASCII.GetBytes(identity.Fingerprint.ToLowerInvariant()))) + { + return MagicProofVerificationResult.Invalid("The supplied public-key fingerprint is invalid."); + } + + return await VerifySignatureAndReplayAsync(identity.PublicKey, request, cancellationToken); + } + + public static bool VerifyContinuity(MagicIdentityContinuityProof proof) + { + var canonical = string.Join("\n", + "MAGICSETTINGS-IDENTITY-CONTINUITY-V1", + proof.PreviousIdentity.NodeId.ToString("D"), + proof.PreviousIdentity.CredentialId.ToString("D"), + proof.NewIdentity.CredentialId.ToString("D"), + proof.NewIdentity.PublicKey, + proof.IssuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + proof.Nonce); + return VerifySignature(proof.PreviousIdentity.PublicKey, canonical, proof.Signature) + && proof.PreviousIdentity.NodeId == proof.NewIdentity.NodeId; + } + + private MagicProofVerificationResult ValidateRequest(MagicProofVerificationRequest request) + { + ArgumentNullException.ThrowIfNull(request); + var proof = request.Proof; + if (!string.Equals(proof.Version, "MAGICSETTINGS-PROOF-V1", StringComparison.Ordinal)) + return MagicProofVerificationResult.Invalid("Unsupported proof version."); + if (!string.Equals(proof.Audience, request.ExpectedAudience, StringComparison.Ordinal)) + return MagicProofVerificationResult.Invalid("The proof audience does not match this API."); + if (!string.Equals(proof.Method, request.Method, StringComparison.OrdinalIgnoreCase)) + return MagicProofVerificationResult.Invalid("The HTTP method does not match the signed proof."); + if (!string.Equals(proof.Target, NormalizeTarget(request.Uri), StringComparison.Ordinal)) + return MagicProofVerificationResult.Invalid("The request target does not match the signed proof."); + if (!string.Equals(proof.BodySha256, request.BodySha256, StringComparison.OrdinalIgnoreCase)) + return MagicProofVerificationResult.Invalid("The request body hash does not match the signed proof."); + if (proof.IssuedUtc - _allowedClockSkew > request.NowUtc) + return MagicProofVerificationResult.Invalid("The proof was issued in the future."); + if (proof.ExpiresUtc + _allowedClockSkew < request.NowUtc) + return MagicProofVerificationResult.Invalid("The proof has expired."); + if (proof.ExpiresUtc <= proof.IssuedUtc || proof.ExpiresUtc - proof.IssuedUtc > TimeSpan.FromMinutes(5)) + return MagicProofVerificationResult.Invalid("The proof lifetime is invalid."); + if (string.IsNullOrWhiteSpace(proof.Nonce)) + return MagicProofVerificationResult.Invalid("The proof nonce is missing."); + return MagicProofVerificationResult.Valid; + } + + private async ValueTask VerifySignatureAndReplayAsync( + string publicKey, + MagicProofVerificationRequest request, + CancellationToken cancellationToken) + { + var proof = request.Proof; + if (!VerifySignature(publicKey, Canonicalize(proof), proof.Signature)) + { + return MagicProofVerificationResult.Invalid("The proof signature is invalid."); + } + + if (!await _replayCache.TryUseAsync(proof.CredentialId, proof.Nonce, proof.ExpiresUtc, cancellationToken)) + { + return MagicProofVerificationResult.Invalid("The proof nonce has already been used."); + } + + return MagicProofVerificationResult.Valid; + } + + private static bool VerifySignature(string publicKey, string canonical, string signature) + { + try + { + using var key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _); + return key.VerifyData( + Encoding.UTF8.GetBytes(canonical), + Convert.FromBase64String(signature), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + catch (CryptographicException) + { + return false; + } + catch (FormatException) + { + return false; + } + } + + 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(System.Globalization.CultureInfo.InvariantCulture), + proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + proof.Nonce); + + private static string NormalizeTarget(Uri uri) + => uri.IsAbsoluteUri + ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped) + : uri.OriginalString; +} +} +namespace MagicSettings.Server +{ +public interface IMagicSecretResolver +{ + ValueTask ResolveAsync( + Guid nodeId, + string name, + CancellationToken cancellationToken = default); +} + +public sealed class MagicSecretService +{ + private readonly MagicNodeProofVerifier _proofVerifier; + private readonly IMagicSecretResolver _resolver; + + public MagicSecretService(MagicNodeProofVerifier proofVerifier, IMagicSecretResolver resolver) + { + _proofVerifier = proofVerifier; + _resolver = resolver; + } + + public async ValueTask ResolveAsync( + MagicSecretRequest request, + string authorityAudience, + Uri requestUri, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (request.NodeId != request.Proof.NodeId || request.CredentialId != request.Proof.CredentialId) + { + throw new UnauthorizedAccessException("The secret request identity does not match its proof."); + } + + var verification = await _proofVerifier.VerifyAsync( + new( + request.Proof, + authorityAudience, + "POST", + requestUri, + MagicSecretProof.ComputeBodySha256(request.Name), + DateTimeOffset.UtcNow), + cancellationToken); + if (!verification.IsValid) + { + throw new UnauthorizedAccessException(verification.Error); + } + + return await _resolver.ResolveAsync(request.NodeId, request.Name, cancellationToken); + } +} +} diff --git a/MagicSettings/MagicSettings.Server/Source.02.cs b/MagicSettings/MagicSettings.Server/Source.02.cs new file mode 100644 index 0000000..a937d12 --- /dev/null +++ b/MagicSettings/MagicSettings.Server/Source.02.cs @@ -0,0 +1,164 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Security.Cryptography; +using MagicSettings.Share; +using Microsoft.AspNetCore.Http; +using System.Collections.Concurrent; +using System.Text; + +namespace MagicSettings.Server +{ +public sealed record MagicRegisteredCredential( + Guid NodeId, + Guid CredentialId, + string PublicKey, + MagicCredentialStatus Status, + DateTimeOffset UpdatedUtc); + +public interface IMagicCredentialRegistry +{ + ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default); + ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default); +} + +public interface IMagicReplayCache +{ + ValueTask TryUseAsync(Guid credentialId, string nonce, DateTimeOffset expiresUtc, CancellationToken cancellationToken = default); +} + +public sealed record MagicNodeRemoteRecord( + Guid NodeId, + string ApplicationId, + int SchemaVersion, + string SchemaFingerprint, + long Revision, + MagicRemoteSnapshot Snapshot, + IReadOnlyList PendingReviewItems, + DateTimeOffset LastContactUtc); + +public interface IMagicNodeRemoteRecordStore +{ + ValueTask GetAsync(Guid nodeId, string applicationId, CancellationToken cancellationToken = default); + ValueTask SaveAsync(MagicNodeRemoteRecord record, CancellationToken cancellationToken = default); +} +} +namespace MagicSettings.Server +{ +public sealed class MagicSettingsSyncService +{ + private readonly IMagicCredentialRegistry _credentials; + private readonly IMagicNodeRemoteRecordStore _records; + private readonly MagicNodeProofVerifier _proofVerifier; + private readonly MagicCredentialRotationService _rotationService; + private readonly bool _autoApproveRotatedCredentials; + + public MagicSettingsSyncService( + IMagicCredentialRegistry credentials, + IMagicNodeRemoteRecordStore records, + MagicNodeProofVerifier proofVerifier, + bool autoApproveRotatedCredentials = false) + { + _credentials = credentials; + _records = records; + _proofVerifier = proofVerifier; + _rotationService = new MagicCredentialRotationService(credentials); + _autoApproveRotatedCredentials = autoApproveRotatedCredentials; + } + + public async ValueTask SynchronizeAsync( + MagicSettingsSyncRequest request, + string authorityAudience, + Uri requestUri, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + var signedPayloadHash = MagicSettingsSyncProof.ComputeBodySha256( + request.Identity, + request.Manifest, + request.LastRemoteRevision, + request.MigrationReport, + request.IdentityContinuityProof); + var verificationRequest = new MagicProofVerificationRequest( + request.Proof, + authorityAudience, + "POST", + requestUri, + signedPayloadHash, + DateTimeOffset.UtcNow); + + var registered = await _credentials.FindAsync(request.Identity.NodeId, request.Identity.CredentialId, cancellationToken); + if (registered is null && request.IdentityContinuityProof is { } continuity) + { + if (continuity.NewIdentity.NodeId != request.Identity.NodeId + || continuity.NewIdentity.CredentialId != request.Identity.CredentialId + || !string.Equals(continuity.NewIdentity.PublicKey, request.Identity.PublicKey, StringComparison.Ordinal)) + { + return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, "The rotation proof does not describe the supplied current identity."); + } + + var rotation = await _rotationService.ApplyAsync(continuity, _autoApproveRotatedCredentials, cancellationToken); + if (!rotation.IsValid) + { + return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, rotation.Error); + } + + registered = await _credentials.FindAsync(request.Identity.NodeId, request.Identity.CredentialId, cancellationToken); + } + + if (registered is null) + { + var enrollmentVerification = await _proofVerifier.VerifyEnrollmentAsync(request.Identity, verificationRequest, cancellationToken); + if (!enrollmentVerification.IsValid) + { + return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, enrollmentVerification.Error); + } + + await _credentials.UpsertAsync( + new(request.Identity.NodeId, request.Identity.CredentialId, request.Identity.PublicKey, MagicCredentialStatus.Pending, DateTimeOffset.UtcNow), + cancellationToken); + return new(MagicControlPlaneState.PendingApproval, MagicRemoteSnapshot.Empty, "Node credential is pending approval."); + } + + if (registered.Status == MagicCredentialStatus.Pending) + { + return new(MagicControlPlaneState.PendingApproval, MagicRemoteSnapshot.Empty, "Node credential is pending approval."); + } + + if (!string.Equals(registered.PublicKey, request.Identity.PublicKey, StringComparison.Ordinal)) + { + return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, "The supplied identity does not match the registered credential."); + } + + var verification = await _proofVerifier.VerifyAsync(verificationRequest, cancellationToken); + if (!verification.IsValid) + { + return new(MagicControlPlaneState.Faulted, MagicRemoteSnapshot.Empty, verification.Error); + } + + var existing = await _records.GetAsync(request.Identity.NodeId, request.Manifest.ApplicationId, cancellationToken); + var reviewItems = existing?.PendingReviewItems.ToList() ?? new List(); + if (request.MigrationReport is { } migrationReport) + { + foreach (var item in migrationReport.ReviewItems) + { + if (!reviewItems.Contains(item)) + { + reviewItems.Add(item); + } + } + } + + var snapshot = existing?.Snapshot ?? MagicRemoteSnapshot.Empty; + var updated = new MagicNodeRemoteRecord( + request.Identity.NodeId, + request.Manifest.ApplicationId, + request.Manifest.SchemaVersion, + request.Manifest.SchemaFingerprint, + existing?.Revision ?? 0, + snapshot, + reviewItems, + DateTimeOffset.UtcNow); + await _records.SaveAsync(updated, cancellationToken); + return new(MagicControlPlaneState.Active, snapshot); + } +} +} diff --git a/MagicSettings/MagicSettings.Share/ConfigurationContracts.cs b/MagicSettings/MagicSettings.Share/ConfigurationContracts.cs new file mode 100644 index 0000000..996d27c --- /dev/null +++ b/MagicSettings/MagicSettings.Share/ConfigurationContracts.cs @@ -0,0 +1,73 @@ +using System.Collections.ObjectModel; +using System.Text.Json; + +namespace MagicSettings.Share; + +public enum MagicValueState +{ + Value, + Null +} + +public enum MagicRemoteValueDurability +{ + Sticky, + Refreshable, + Expiring +} + +public sealed record MagicRemoteValue( + MagicValueState State, + JsonElement? Value, + MagicRemoteValueDurability Durability = MagicRemoteValueDurability.Sticky, + DateTimeOffset? ExpiresUtc = null) +{ + public static MagicRemoteValue From(T value, MagicRemoteValueDurability durability = MagicRemoteValueDurability.Sticky, DateTimeOffset? expiresUtc = null) + => new(MagicValueState.Value, JsonSerializer.SerializeToElement(value), durability, expiresUtc); + + public static MagicRemoteValue ExplicitNull(MagicRemoteValueDurability durability = MagicRemoteValueDurability.Sticky, DateTimeOffset? expiresUtc = null) + => new(MagicValueState.Null, null, durability, expiresUtc); +} + +public sealed record MagicRemoteSnapshot( + long Revision, + DateTimeOffset IssuedUtc, + IReadOnlyDictionary Values) +{ + public static MagicRemoteSnapshot Empty { get; } = new(0, DateTimeOffset.MinValue, new ReadOnlyDictionary(new Dictionary())); +} + +public sealed record MagicSettingManifestEntry( + string Path, + string Type, + bool Nullable, + bool Sensitive, + bool RemoteOverrideAllowed, + string? Description = null); + +public sealed record MagicSettingsSchemaManifest( + string ApplicationId, + string ApplicationVersion, + int SchemaVersion, + string SchemaFingerprint, + IReadOnlyList Settings); + +public enum MagicMigrationReviewSeverity +{ + Information, + Warning, + Destructive +} + +public sealed record MagicMigrationReviewItem( + string Path, + string Operation, + MagicMigrationReviewSeverity Severity, + string Reason, + string? ProposedPath = null); + +public sealed record MagicSettingsMigrationReport( + int FromVersion, + int ToVersion, + IReadOnlyList SafeOperations, + IReadOnlyList ReviewItems); diff --git a/MagicSettings/MagicSettings.Share/ControlPlaneContracts.cs b/MagicSettings/MagicSettings.Share/ControlPlaneContracts.cs new file mode 100644 index 0000000..ff19f55 --- /dev/null +++ b/MagicSettings/MagicSettings.Share/ControlPlaneContracts.cs @@ -0,0 +1,58 @@ +namespace MagicSettings.Share; + +public enum MagicControlPlaneEndpointSource +{ + None, + CodeFallback, + PersistentSettings, + EnvironmentVariable, + RuntimeOverride +} + +public sealed record MagicResolvedControlPlaneEndpoint(Uri? Endpoint, MagicControlPlaneEndpointSource Source) +{ + public static MagicResolvedControlPlaneEndpoint None { get; } = new(null, MagicControlPlaneEndpointSource.None); +} + +public enum MagicControlPlaneState +{ + Disabled, + Configured, + Connecting, + PendingApproval, + Active, + Disconnected, + Faulted +} + +public enum MagicControlPlaneTrustMode +{ + SystemTls, + PinnedPublicKey +} + +public sealed record MagicControlPlaneTrust( + MagicControlPlaneTrustMode Mode, + string AuthorityId, + string? PinnedPublicKeyFingerprint = null) +{ + public static MagicControlPlaneTrust SystemTls(string authorityId) => new(MagicControlPlaneTrustMode.SystemTls, authorityId); + public static MagicControlPlaneTrust Pinned(string authorityId, string fingerprint) => new(MagicControlPlaneTrustMode.PinnedPublicKey, authorityId, fingerprint); +} + +public sealed record MagicSettingsSyncRequest( + MagicNodeIdentityDescriptor Identity, + MagicAuthenticationProof Proof, + MagicSettingsSchemaManifest Manifest, + long LastRemoteRevision, + MagicSettingsMigrationReport? MigrationReport = null, + MagicIdentityContinuityProof? IdentityContinuityProof = null); + +public sealed record MagicSettingsSyncResponse( + MagicControlPlaneState State, + MagicRemoteSnapshot Snapshot, + string? Message = null, + TimeSpan? SuggestedPollInterval = null); + +public sealed record MagicSecretRequest(Guid NodeId, Guid CredentialId, string Name, MagicAuthenticationProof Proof); +public sealed record MagicSecretResponse(bool Found, string? Value, DateTimeOffset? ExpiresUtc = null); diff --git a/MagicSettings/MagicSettings.Share/MagicSettings.Share.csproj b/MagicSettings/MagicSettings.Share/MagicSettings.Share.csproj index 237d661..ecaa002 100644 --- a/MagicSettings/MagicSettings.Share/MagicSettings.Share.csproj +++ b/MagicSettings/MagicSettings.Share/MagicSettings.Share.csproj @@ -1,9 +1,6 @@ - - - - net10.0 - enable - enable - - + + + MagicSettings.Share + Shared protocol, schema, and security contracts for MagicSettings clients and control-plane implementations. + diff --git a/MagicSettings/MagicSettings.Share/ProofCodec.cs b/MagicSettings/MagicSettings.Share/ProofCodec.cs new file mode 100644 index 0000000..09dba9e --- /dev/null +++ b/MagicSettings/MagicSettings.Share/ProofCodec.cs @@ -0,0 +1,26 @@ +using System.Text; +using System.Text.Json; + +namespace MagicSettings.Share; + +public static class MagicNodeProofCodec +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public static string Encode(MagicAuthenticationProof proof) + => Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes(proof, Json)); + + public static MagicAuthenticationProof Decode(string encoded) + => JsonSerializer.Deserialize(Base64UrlDecode(encoded), Json) + ?? throw new FormatException("The MagicSettings authentication proof is invalid."); + + public static string Base64UrlEncode(ReadOnlySpan value) + => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + public static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + padded += new string('=', (4 - padded.Length % 4) % 4); + return Convert.FromBase64String(padded); + } +} diff --git a/MagicSettings/MagicSettings.Share/SecretProof.cs b/MagicSettings/MagicSettings.Share/SecretProof.cs new file mode 100644 index 0000000..de4b359 --- /dev/null +++ b/MagicSettings/MagicSettings.Share/SecretProof.cs @@ -0,0 +1,13 @@ +using System.Security.Cryptography; +using System.Text; + +namespace MagicSettings.Share; + +public static class MagicSecretProof +{ + public static string ComputeBodySha256(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(name))).ToLowerInvariant(); + } +} diff --git a/MagicSettings/MagicSettings.Share/SecurityContracts.cs b/MagicSettings/MagicSettings.Share/SecurityContracts.cs new file mode 100644 index 0000000..d158f4a --- /dev/null +++ b/MagicSettings/MagicSettings.Share/SecurityContracts.cs @@ -0,0 +1,80 @@ +namespace MagicSettings.Share; + +public enum MagicCredentialKind +{ + EcdsaP256 +} + +public enum MagicCredentialStatus +{ + Pending, + Approved, + Retiring, + Revoked +} + +public sealed record MagicNodeIdentityDescriptor( + Guid NodeId, + Guid CredentialId, + MagicCredentialKind CredentialKind, + string SignatureAlgorithm, + string PublicKey, + string Fingerprint, + DateTimeOffset CreatedUtc); + +public sealed record MagicAuthenticationRequest( + string Audience, + string Method, + Uri Uri, + string BodySha256, + TimeSpan? ValidFor = null); + +public sealed record MagicAuthenticationProof( + string Version, + Guid NodeId, + Guid CredentialId, + string Audience, + string Method, + string Target, + string BodySha256, + DateTimeOffset IssuedUtc, + DateTimeOffset ExpiresUtc, + string Nonce, + string Signature); + +public sealed record MagicIdentityContinuityProof( + MagicNodeIdentityDescriptor PreviousIdentity, + MagicNodeIdentityDescriptor NewIdentity, + DateTimeOffset IssuedUtc, + string Nonce, + string Signature); + +public enum MagicIdentityChangeKind +{ + Rotated, + Reset, + RecoveredAfterLoss +} + +public sealed record MagicIdentityChange( + MagicIdentityChangeKind Kind, + MagicNodeIdentityDescriptor Current, + MagicNodeIdentityDescriptor? Previous, + MagicIdentityContinuityProof? ContinuityProof, + string Reason); + +public sealed record MagicIdentityResetRequest(string Reason, bool ConfirmDestructiveReset); + +public sealed record MagicProofVerificationRequest( + MagicAuthenticationProof Proof, + string ExpectedAudience, + string Method, + Uri Uri, + string BodySha256, + DateTimeOffset NowUtc); + +public sealed record MagicProofVerificationResult(bool IsValid, string? Error) +{ + public static MagicProofVerificationResult Valid { get; } = new(true, null); + public static MagicProofVerificationResult Invalid(string error) => new(false, error); +} diff --git a/MagicSettings/MagicSettings.Share/SyncProof.cs b/MagicSettings/MagicSettings.Share/SyncProof.cs new file mode 100644 index 0000000..5e92a1a --- /dev/null +++ b/MagicSettings/MagicSettings.Share/SyncProof.cs @@ -0,0 +1,50 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace MagicSettings.Share; + +/// +/// Produces the deterministic payload hash signed for a control-plane synchronization request. +/// The proof itself is intentionally excluded so the request can be signed without a circular dependency. +/// +public static class MagicSettingsSyncProof +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public static string ComputeBodySha256( + MagicNodeIdentityDescriptor identity, + MagicSettingsSchemaManifest manifest, + long lastRemoteRevision, + MagicSettingsMigrationReport? migrationReport, + MagicIdentityContinuityProof? identityContinuityProof = null) + { + ArgumentNullException.ThrowIfNull(identity); + ArgumentNullException.ThrowIfNull(manifest); + + var payload = new MagicSettingsSyncSigningPayload( + identity.NodeId, + identity.CredentialId, + identity.PublicKey, + manifest.ApplicationId, + manifest.ApplicationVersion, + manifest.SchemaVersion, + manifest.SchemaFingerprint, + lastRemoteRevision, + migrationReport, + identityContinuityProof); + + return Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(payload, Json))).ToLowerInvariant(); + } + + private sealed record MagicSettingsSyncSigningPayload( + Guid NodeId, + Guid CredentialId, + string PublicKey, + string ApplicationId, + string ApplicationVersion, + int SchemaVersion, + string SchemaFingerprint, + long LastRemoteRevision, + MagicSettingsMigrationReport? MigrationReport, + MagicIdentityContinuityProof? IdentityContinuityProof); +} diff --git a/MagicSettings/MagicSettings.Tests/MagicSettings.Tests.csproj b/MagicSettings/MagicSettings.Tests/MagicSettings.Tests.csproj index 68b9f9b..eee3404 100644 --- a/MagicSettings/MagicSettings.Tests/MagicSettings.Tests.csproj +++ b/MagicSettings/MagicSettings.Tests/MagicSettings.Tests.csproj @@ -1,23 +1,24 @@ - - - - net10.0 - latest - enable - enable - false - - - - - - - - - - - - - - + + + false + true + false + false + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/MagicSettings/MagicSettings.Tests/Source.01.cs b/MagicSettings/MagicSettings.Tests/Source.01.cs new file mode 100644 index 0000000..d5fc0ec --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Source.01.cs @@ -0,0 +1,256 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +global using Xunit; +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests +{ +public sealed class CredentialAdministrationTests +{ + [Fact] + public async Task RevokedCredentialCanNoLongerAuthenticate() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var administration = new MagicCredentialAdministrationService(registry); + Assert.True(await administration.RevokeAsync(identity.NodeId, identity.CredentialId)); + + var uri = new Uri("https://api.example/resource"); + var proof = await manager.CreateProofAsync(new("Resource.Api", "GET", uri, MagicHash.EmptySha256)); + var result = await new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()).VerifyAsync( + new(proof, "Resource.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); + + Assert.False(result.IsValid); + Assert.Equal("The credential is Revoked.", result.Error); + } +} +} +namespace MagicSettings.Tests +{ +public sealed class CredentialRotationTests +{ + [Fact] + public async Task ServerCanApproveRotatedCredentialWithoutReceivingPrivateKey() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var initial = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(initial.NodeId, initial.CredentialId, initial.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var rotation = await manager.RotateAsync("scheduled"); + + var result = await new MagicCredentialRotationService(registry).ApplyAsync(rotation.ContinuityProof!, autoApproveNewCredential: true); + var oldCredential = await registry.FindAsync(initial.NodeId, initial.CredentialId); + var newCredential = await registry.FindAsync(rotation.Current.NodeId, rotation.Current.CredentialId); + + Assert.True(result.IsValid); + Assert.Equal(MagicCredentialStatus.Retiring, oldCredential!.Status); + Assert.Equal(MagicCredentialStatus.Approved, newCredential!.Status); + } + [Fact] + public async Task SyncCanCarryContinuityProofAndAutoApproveRotatedCredential() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var initial = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(initial.NodeId, initial.CredentialId, initial.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var change = await manager.RotateAsync("scheduled"); + var records = new InMemoryMagicNodeRemoteRecordStore(); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var service = new MagicSettingsSyncService(registry, records, verifier, autoApproveRotatedCredentials: true); + var uri = new Uri("https://control.example/magicsettings/sync"); + var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint", []); + var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(change.Current, manifest, 0, null, change.ContinuityProof); + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); + + var response = await service.SynchronizeAsync( + new(change.Current, proof, manifest, 0, null, change.ContinuityProof), + "Control.Api", + uri); + + Assert.Equal(MagicControlPlaneState.Active, response.State); + Assert.Equal( + MagicCredentialStatus.Approved, + (await registry.FindAsync(change.Current.NodeId, change.Current.CredentialId))!.Status); + } + +} +} +namespace MagicSettings.Tests +{ +public sealed class EndpointBootstrapTests +{ + [Fact] + public void Resolver_UsesEnvironmentBeforePersistentAndFallback() + { + const string variable = "MAGICSETTINGS_TEST_CONTROL_PLANE"; + var previous = Environment.GetEnvironmentVariable(variable); + try + { + Environment.SetEnvironmentVariable(variable, "https://environment.example/"); + var options = new MagicSettingsOptions(); + options.ControlPlane.Bootstrap.EnvironmentVariableName = variable; + options.ControlPlane.Bootstrap.PersistentSettingPath = "MagicSettings:ControlPlane:Endpoint"; + options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("https://fallback.example/"); + var document = JsonNode.Parse("""{"MagicSettings":{"ControlPlane":{"Endpoint":"https://file.example/"}}}""")!.AsObject(); + + var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, document); + + Assert.Equal(MagicControlPlaneEndpointSource.EnvironmentVariable, resolved.Source); + Assert.Equal("https://environment.example/", resolved.Endpoint!.AbsoluteUri); + } + finally + { + Environment.SetEnvironmentVariable(variable, previous); + } + } + + [Fact] + public void Resolver_UsesPersistentFileWithoutConsultingRemoteState() + { + var options = new MagicSettingsOptions(); + options.ControlPlane.Bootstrap.EnvironmentVariableName = "MAGICSETTINGS_TEST_MISSING_ENDPOINT"; + options.ControlPlane.Bootstrap.PersistentSettingPath = "MagicSettings:ControlPlane:Endpoint"; + var local = JsonNode.Parse("""{"MagicSettings":{"ControlPlane":{"Endpoint":"https://local.example/"}}}""")!.AsObject(); + + var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, local); + + Assert.Equal(MagicControlPlaneEndpointSource.PersistentSettings, resolved.Source); + Assert.Equal("https://local.example/", resolved.Endpoint!.AbsoluteUri); + } + + [Fact] + public void Resolver_RejectsNonLoopbackHttpUnlessExplicitlyAllowed() + { + var options = new MagicSettingsOptions(); + options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("http://control.example/"); + + Assert.Throws(() => + new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject())); + + options.ControlPlane.Bootstrap.AllowInsecureHttp = true; + var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject()); + Assert.Equal("http://control.example/", resolved.Endpoint!.AbsoluteUri); + } + + [Fact] + public void RuntimeOverride_IsHighestPriority() + { + var options = new MagicSettingsOptions(); + options.ControlPlane.Bootstrap.CodeFallbackEndpoint = new Uri("https://fallback.example/"); + var resolved = new MagicControlPlaneEndpointResolver().Resolve(options, new JsonObject(), new Uri("https://runtime.example/")); + Assert.Equal(MagicControlPlaneEndpointSource.RuntimeOverride, resolved.Source); + Assert.Equal("https://runtime.example/", resolved.Endpoint!.AbsoluteUri); + } +} +} +namespace MagicSettings.Tests +{ +public sealed class EnrollmentTests +{ + [Fact] + public async Task UnknownNodeMustProvePossessionBeforeBeingRegisteredAsPending() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + var service = new MagicSettingsSyncService( + registry, + new InMemoryMagicNodeRemoteRecordStore(), + new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache())); + var uri = new Uri("https://control.example/magicsettings/sync"); + var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint", []); + var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, manifest, 0, null); + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); + + var response = await service.SynchronizeAsync(new(identity, proof, manifest, 0), "Control.Api", uri); + var registered = await registry.FindAsync(identity.NodeId, identity.CredentialId); + + Assert.Equal(MagicControlPlaneState.PendingApproval, response.State); + Assert.Equal(MagicCredentialStatus.Pending, registered!.Status); + } + + [Fact] + public async Task UnknownNodeWithTamperedManifestIsRejected() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + var service = new MagicSettingsSyncService( + registry, + new InMemoryMagicNodeRemoteRecordStore(), + new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache())); + var uri = new Uri("https://control.example/magicsettings/sync"); + var signedManifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 1, "fingerprint-a", []); + var sentManifest = signedManifest with { SchemaFingerprint = "fingerprint-b" }; + var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, signedManifest, 0, null); + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); + + var response = await service.SynchronizeAsync(new(identity, proof, sentManifest, 0), "Control.Api", uri); + + Assert.Equal(MagicControlPlaneState.Faulted, response.State); + Assert.Null(await registry.FindAsync(identity.NodeId, identity.CredentialId)); + } +} +} +namespace MagicSettings.Tests +{ +public sealed class IdentityTests +{ + [Fact] + public async Task Identity_IsStableAcrossManagerInstances() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "identity.json"); + var first = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(path)); + var firstIdentity = await first.GetCurrentAsync(); + var second = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(path)); + var secondIdentity = await second.GetCurrentAsync(); + + Assert.Equal(firstIdentity.NodeId, secondIdentity.NodeId); + Assert.Equal(firstIdentity.CredentialId, secondIdentity.CredentialId); + Assert.Equal(firstIdentity.Fingerprint, secondIdentity.Fingerprint); + } + + [Fact] + public async Task Rotation_KeepsNodeAndProducesVerifiableContinuityProof() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var before = await manager.GetCurrentAsync(); + + var change = await manager.RotateAsync("scheduled rotation"); + + Assert.Equal(MagicIdentityChangeKind.Rotated, change.Kind); + Assert.Equal(before.NodeId, change.Current.NodeId); + Assert.NotEqual(before.CredentialId, change.Current.CredentialId); + Assert.NotNull(change.ContinuityProof); + Assert.True(MagicNodeProofVerifier.VerifyContinuity(change.ContinuityProof!)); + } + + [Fact] + public async Task Reset_ChangesNodeAndRequiresExplicitConfirmation() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var before = await manager.GetCurrentAsync(); + + await Assert.ThrowsAsync(async () => + await manager.ResetAsync(new("test", false))); + + var change = await manager.ResetAsync(new("compromised", true)); + Assert.Equal(MagicIdentityChangeKind.Reset, change.Kind); + Assert.NotEqual(before.NodeId, change.Current.NodeId); + Assert.Null(change.ContinuityProof); + } +} +} diff --git a/MagicSettings/MagicSettings.Tests/Source.02.cs b/MagicSettings/MagicSettings.Tests/Source.02.cs new file mode 100644 index 0000000..fc75ce7 --- /dev/null +++ b/MagicSettings/MagicSettings.Tests/Source.02.cs @@ -0,0 +1,378 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +global using Xunit; +using MagicSettings.Server; +using MagicSettings.Share; +using System.Text.Json.Nodes; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace MagicSettings.Tests +{ +public sealed class InitializationTests +{ + [Fact] + public async Task Initialization_GeneratesFileAndPreservesExistingValuesWhileAddingDefaults() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + var builder = new HostApplicationBuilder(); + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Template.Database.Host = "template-host"; + options.Template.Database.Port = 5432; + }); + + var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + document["Database"]!["Host"] = "operator-host"; + document["Database"]!.AsObject().Remove("Port"); + await File.WriteAllTextAsync(path, document.ToJsonString(new() { WriteIndented = true })); + + var secondBuilder = new HostApplicationBuilder(); + await secondBuilder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Template.Database.Host = "new-template-host"; + options.Template.Database.Port = 7777; + }); + + var reconciled = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + Assert.Equal("operator-host", reconciled["Database"]!["Host"]!.GetValue()); + Assert.Equal(7777, reconciled["Database"]!["Port"]!.GetValue()); + } + + [Fact] + public async Task RemoteSnapshot_OverridesEnvironmentAndFileWithoutPersisting() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + var transport = new FakeTransport("Database:Host", MagicRemoteValue.From("remote-host")); + var previous = Environment.GetEnvironmentVariable("MagicSettings__Database__Host"); + Environment.SetEnvironmentVariable("MagicSettings__Database__Host", "environment-host"); + try + { + var builder = new HostApplicationBuilder(); + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Template.Database.Host = "file-host"; + options.ControlPlaneTransport = transport; + }); + await using var provider = builder.Services.BuildServiceProvider(); + var controlPlane = provider.GetRequiredService(); + await controlPlane.ConfigureAsync(new Uri("https://control.example/"), MagicControlPlaneTrust.SystemTls("Control.Api")); + var settings = provider.GetRequiredService>(); + + Assert.Equal("remote-host", settings.Current.Database.Host); + var file = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + Assert.Equal("file-host", file["Database"]!["Host"]!.GetValue()); + } + finally + { + Environment.SetEnvironmentVariable("MagicSettings__Database__Host", previous); + } + } + + [Fact] + public async Task RemoteSnapshot_CannotOverrideControlPlaneBootstrapEndpoint() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + var transport = new FakeTransport( + "MagicSettings:ControlPlane:Endpoint", + MagicRemoteValue.From("https://attacker.example/")); + var builder = new HostApplicationBuilder(); + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Template.MagicSettings.ControlPlane.Endpoint = "https://trusted-local.example/"; + options.ControlPlaneTransport = transport; + }); + await using var provider = builder.Services.BuildServiceProvider(); + await provider.GetRequiredService().ConfigureAsync( + new Uri("https://control.example/"), + MagicControlPlaneTrust.SystemTls("Control.Api")); + + var settings = provider.GetRequiredService>(); + Assert.Equal("https://trusted-local.example/", settings.Current.MagicSettings.ControlPlane.Endpoint); + } + + [Fact] + public async Task DevelopmentRequiresExplicitArrayPolicy() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + var first = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Development" }); + await first.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Environment = "Development"; + options.Template.Origins = ["https://one.example"]; + }); + + var second = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Development" }); + await Assert.ThrowsAsync(async () => + await second.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.Environment = "Development"; + options.Template.Origins = ["https://one.example", "https://two.example"]; + })); + } + + private sealed class FakeTransport : IMagicControlPlaneTransport + { + private readonly string _path; + private readonly MagicRemoteValue _value; + public FakeTransport(string path, MagicRemoteValue value) + { + _path = path; + _value = value; + } + + public ValueTask SynchronizeAsync(Uri endpoint, MagicControlPlaneTrust trust, MagicSettingsSyncRequest request, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new MagicSettingsSyncResponse( + MagicControlPlaneState.Active, + new MagicRemoteSnapshot(1, DateTimeOffset.UtcNow, new Dictionary + { + [_path] = _value + }))); + } +} +} +namespace MagicSettings.Tests +{ +public sealed class MigrationTests +{ + [Fact] + public async Task SequentialMigrationRenamesAndTransformsWithoutDeletingRemoteHistory() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + await File.WriteAllTextAsync(path, """ + { + "Database": { + "Server": "old-host", + "Port": "7443" + }, + "$magicSettings": { + "schemaVersion": 1 + } + } + """); + + var builder = new HostApplicationBuilder(); + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.SchemaVersion = 2; + options.Migrations.Add(new VersionOneToTwo()); + }); + + var document = JsonNode.Parse(await File.ReadAllTextAsync(path))!.AsObject(); + Assert.Equal("old-host", document["Database"]!["Host"]!.GetValue()); + Assert.Equal(7443, document["Database"]!["Port"]!.GetValue()); + Assert.Equal(2, document["$magicSettings"]!["schemaVersion"]!.GetValue()); + } + + [Fact] + public async Task MissingMigrationFailsStartupInEveryEnvironment() + { + using var directory = new TemporaryDirectory(); + var path = Path.Combine(directory.Path, "settings.json"); + await File.WriteAllTextAsync(path, """{"$magicSettings":{"schemaVersion":1}}"""); + var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings { EnvironmentName = "Production" }); + + await Assert.ThrowsAsync(async () => + await builder.AddMagicSettingsAsync(configure: options => + { + options.Path = path; + options.SchemaVersion = 2; + })); + } + + private sealed class VersionOneToTwo : IMagicSettingsMigration + { + public int FromVersion => 1; + public int ToVersion => 2; + + public void Apply(JsonObject document, MagicMigrationContext context) + { + context.Rename(document, "Database:Server", "Database:Host"); + context.Transform( + document, + "Database:Port", + node => JsonValue.Create(int.Parse(node!.GetValue(), System.Globalization.CultureInfo.InvariantCulture)), + "Convert port from string to integer.", + remoteSafeProjection: false); + } + } +} +} +namespace MagicSettings.Tests +{ +public sealed class ProofTests +{ + [Fact] + public async Task Proof_IsAudienceMethodTargetAndBodyBound_AndCannotReplay() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var uri = new Uri("https://api.example/v1/items?x=1"); + var bodyHash = MagicHash.Sha256Hex("hello"u8); + var proof = await manager.CreateProofAsync(new("Items.Api", "POST", uri, bodyHash)); + var request = new MagicProofVerificationRequest(proof, "Items.Api", "POST", uri, bodyHash, DateTimeOffset.UtcNow); + + Assert.True((await verifier.VerifyAsync(request)).IsValid); + Assert.Equal("The proof nonce has already been used.", (await verifier.VerifyAsync(request)).Error); + } + + [Fact] + public async Task Proof_RejectsWrongAudienceBeforeNonceConsumption() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var uri = new Uri("https://api.example/v1/items"); + var proof = await manager.CreateProofAsync(new("Items.Api", "GET", uri, MagicHash.EmptySha256)); + + var wrong = await verifier.VerifyAsync(new(proof, "Admin.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); + var correct = await verifier.VerifyAsync(new(proof, "Items.Api", "GET", uri, MagicHash.EmptySha256, DateTimeOffset.UtcNow)); + + Assert.False(wrong.IsValid); + Assert.True(correct.IsValid); + } +} +} +namespace MagicSettings.Tests +{ +public sealed class SecretTests +{ + [Fact] + public async Task SecretRequestUsesFreshBoundNodeProof() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var resolver = new FakeSecretResolver(); + var service = new MagicSecretService(verifier, resolver); + var uri = new Uri("https://control.example/magicsettings/secrets/resolve"); + const string name = "Database:Password"; + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, MagicSecretProof.ComputeBodySha256(name))); + + var response = await service.ResolveAsync(new(identity.NodeId, identity.CredentialId, name, proof), "Control.Api", uri); + + Assert.True(response.Found); + Assert.Equal("secret-value", response.Value); + } + + private sealed class FakeSecretResolver : IMagicSecretResolver + { + public ValueTask ResolveAsync(Guid nodeId, string name, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new MagicSecretResponse(true, "secret-value")); + } +} +} +namespace MagicSettings.Tests +{ +public sealed class ServerSafetyTests +{ + [Fact] + public async Task SyncService_RetainsDestructiveMigrationAsManualReview() + { + using var directory = new TemporaryDirectory(); + var manager = new MagicNodeIdentityManager(new FileMagicNodeIdentityStore(Path.Combine(directory.Path, "identity.json"))); + var identity = await manager.GetCurrentAsync(); + var registry = new InMemoryMagicCredentialRegistry(); + await registry.UpsertAsync(new(identity.NodeId, identity.CredentialId, identity.PublicKey, MagicCredentialStatus.Approved, DateTimeOffset.UtcNow)); + var records = new InMemoryMagicNodeRemoteRecordStore(); + var verifier = new MagicNodeProofVerifier(registry, new InMemoryMagicReplayCache()); + var service = new MagicSettingsSyncService(registry, records, verifier); + var uri = new Uri("https://control.example/magicsettings/sync"); + var report = new MagicSettingsMigrationReport(1, 2, [], + [ + new("Secrets:Old", "remove", MagicMigrationReviewSeverity.Destructive, "Client no longer uses this secret.") + ]); + var manifest = new MagicSettingsSchemaManifest("TestApp", "1.0.0", 2, "fingerprint", []); + var bodyHash = MagicSettingsSyncProof.ComputeBodySha256(identity, manifest, 0, report); + var proof = await manager.CreateProofAsync(new("Control.Api", "POST", uri, bodyHash)); + + var response = await service.SynchronizeAsync(new(identity, proof, manifest, 0, report), "Control.Api", uri); + var stored = await records.GetAsync(identity.NodeId, "TestApp"); + + Assert.Equal(MagicControlPlaneState.Active, response.State); + Assert.Single(stored!.PendingReviewItems); + Assert.Equal("Secrets:Old", stored.PendingReviewItems[0].Path); + } +} +} +namespace MagicSettings.Tests +{ +internal sealed class TemporaryDirectory : IDisposable +{ + public TemporaryDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "MagicSettings.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } +} +} +namespace MagicSettings.Tests +{ +public sealed class TestSettings +{ + public TestApplication Application { get; set; } = new(); + public TestDatabase Database { get; set; } = new(); + public TestControlPlane MagicSettings { get; set; } = new(); + public List Origins { get; set; } = new(); +} + +public sealed class TestApplication +{ + public string Name { get; set; } = "Test"; + public bool FeatureEnabled { get; set; } +} + +public sealed class TestDatabase +{ + public string Host { get; set; } = "localhost"; + public string Password { get; set; } = "change-me"; + public int Port { get; set; } = 5432; +} + +public sealed class TestControlPlane +{ + public TestControlPlaneSection ControlPlane { get; set; } = new(); +} + +public sealed class TestControlPlaneSection +{ + public string? Endpoint { get; set; } +} +} diff --git a/MagicSettings/MagicSettings.Tests/UnitTest1.cs b/MagicSettings/MagicSettings.Tests/UnitTest1.cs deleted file mode 100644 index c4dc9ef..0000000 --- a/MagicSettings/MagicSettings.Tests/UnitTest1.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace MagicSettings.Tests; - -public class Tests -{ - [SetUp] - public void Setup() - { - } - - [Test] - public void Test1() - { - Assert.Pass(); - } -} \ No newline at end of file diff --git a/MagicSettings/MagicSettings.sln b/MagicSettings/MagicSettings.sln index 0cb58da..28518a5 100644 --- a/MagicSettings/MagicSettings.sln +++ b/MagicSettings/MagicSettings.sln @@ -1,34 +1,36 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings", "MagicSettings\MagicSettings.csproj", "{C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Tests", "MagicSettings.Tests\MagicSettings.Tests.csproj", "{66496E6F-9EFB-45C2-A981-88D1E315C705}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Share", "MagicSettings.Share\MagicSettings.Share.csproj", "{C492C153-D111-4874-93B3-0E41A5FDEDB1}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Server", "MagicSettings.Server\MagicSettings.Server.csproj", "{876477AA-F425-456B-9E7A-30CC10F67EF7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Share", "MagicSettings.Share\MagicSettings.Share.csproj", "{C492C153-D111-4874-93B3-0E41A5FDEDB1}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagicSettings.Tests", "MagicSettings.Tests\MagicSettings.Tests.csproj", "{66496E6F-9EFB-45C2-A981-88D1E315C705}" EndProject Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Release|Any CPU.Build.0 = Release|Any CPU - {66496E6F-9EFB-45C2-A981-88D1E315C705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {66496E6F-9EFB-45C2-A981-88D1E315C705}.Debug|Any CPU.Build.0 = Debug|Any CPU - {66496E6F-9EFB-45C2-A981-88D1E315C705}.Release|Any CPU.ActiveCfg = Release|Any CPU - {66496E6F-9EFB-45C2-A981-88D1E315C705}.Release|Any CPU.Build.0 = Release|Any CPU - {876477AA-F425-456B-9E7A-30CC10F67EF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {876477AA-F425-456B-9E7A-30CC10F67EF7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {876477AA-F425-456B-9E7A-30CC10F67EF7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {876477AA-F425-456B-9E7A-30CC10F67EF7}.Release|Any CPU.Build.0 = Release|Any CPU - {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C61F3392-5B49-46BB-8BC0-FEEAA830BC5E}.Release|Any CPU.Build.0 = Release|Any CPU + {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C492C153-D111-4874-93B3-0E41A5FDEDB1}.Release|Any CPU.Build.0 = Release|Any CPU + {876477AA-F425-456B-9E7A-30CC10F67EF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {876477AA-F425-456B-9E7A-30CC10F67EF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {876477AA-F425-456B-9E7A-30CC10F67EF7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {876477AA-F425-456B-9E7A-30CC10F67EF7}.Release|Any CPU.Build.0 = Release|Any CPU + {66496E6F-9EFB-45C2-A981-88D1E315C705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {66496E6F-9EFB-45C2-A981-88D1E315C705}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66496E6F-9EFB-45C2-A981-88D1E315C705}.Release|Any CPU.ActiveCfg = Release|Any CPU + {66496E6F-9EFB-45C2-A981-88D1E315C705}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection EndGlobal diff --git a/MagicSettings/MagicSettings/MagicSettings.csproj b/MagicSettings/MagicSettings/MagicSettings.csproj index 237d661..336d442 100644 --- a/MagicSettings/MagicSettings/MagicSettings.csproj +++ b/MagicSettings/MagicSettings/MagicSettings.csproj @@ -1,9 +1,10 @@ - - - - net10.0 - enable - enable - - + + + MagicSettings + Strongly typed configuration with generation, reconciliation, migrations, live overrides, and client-owned control-plane integration. + + + + + diff --git a/MagicSettings/MagicSettings/Source.01.cs b/MagicSettings/MagicSettings/Source.01.cs new file mode 100644 index 0000000..73223f4 --- /dev/null +++ b/MagicSettings/MagicSettings/Source.01.cs @@ -0,0 +1,163 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings +{ +public interface IMagicSettings where TSettings : class +{ + TSettings Current { get; } + long Revision { get; } + event EventHandler>? Changed; + MagicSettingExplanation Explain(string path); +} + +public sealed record MagicSettingsChangedEventArgs(TSettings Previous, TSettings Current, long Revision) where TSettings : class; + +public sealed record MagicSettingExplanation( + string Path, + string? EffectiveValue, + string EffectiveSource, + IReadOnlyList Sources, + bool IsSensitive); + +public sealed record MagicSettingSourceValue(string Source, bool Present, string? Value); + +public interface IMagicSettingsValidator where TSettings : class +{ + ValueTask> ValidateAsync(TSettings settings, CancellationToken cancellationToken = default); +} + +public interface IMagicSettingsSourceProvider +{ + string Name { get; } + int Priority { get; } + ValueTask> LoadAsync(CancellationToken cancellationToken = default); +} + +public interface IMagicSettingsMigration +{ + int FromVersion { get; } + int ToVersion { get; } + void Apply(JsonObject document, MagicMigrationContext context); +} + +public interface IMagicSecretProvider +{ + ValueTask> GetAsync(string name, CancellationToken cancellationToken = default); +} + +public sealed class MagicSecretLease : IAsyncDisposable +{ + public MagicSecretLease(T value, DateTimeOffset? expiresUtc = null) + { + Value = value; + ExpiresUtc = expiresUtc; + } + + public T Value { get; } + public DateTimeOffset? ExpiresUtc { get; } + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} + +public interface IMagicControlPlaneEndpointResolver +{ + MagicResolvedControlPlaneEndpoint Resolve( + MagicSettingsOptions options, + JsonObject persistentDocument, + Uri? runtimeOverride = null) where TSettings : class, new(); +} + +public interface IMagicSettingsControlPlane +{ + MagicControlPlaneState State { get; } + MagicResolvedControlPlaneEndpoint CurrentEndpoint { get; } + ValueTask ConfigureAsync(Uri endpoint, MagicControlPlaneTrust trust, CancellationToken cancellationToken = default); + ValueTask DisconnectAsync(bool clearRemoteOverrides = false, CancellationToken cancellationToken = default); + ValueTask RefreshAsync(CancellationToken cancellationToken = default); +} + +public interface IMagicControlPlaneTransport +{ + ValueTask SynchronizeAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSettingsSyncRequest request, + CancellationToken cancellationToken = default); +} + + +public interface IMagicSecretTransport +{ + ValueTask ResolveSecretAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSecretRequest request, + CancellationToken cancellationToken = default); +} + +public interface IMagicNodeAuthenticator +{ + ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default); + ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default); +} + +public interface IMagicNodeIdentityManager +{ + event EventHandler? IdentityChanged; + ValueTask GetCurrentAsync(CancellationToken cancellationToken = default); + ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default); + ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default); +} + +public interface IMagicNodeIdentityStore +{ + ValueTask LoadAsync(CancellationToken cancellationToken = default); + ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default); + ValueTask DeleteAsync(CancellationToken cancellationToken = default); +} + +public sealed record MagicStoredNodeIdentity( + Guid NodeId, + Guid CredentialId, + string PublicKey, + string PrivateKey, + DateTimeOffset CreatedUtc); +} +namespace MagicSettings +{ +[AttributeUsage(AttributeTargets.Property)] +public sealed class MagicSensitiveAttribute : Attribute +{ +} + +[AttributeUsage(AttributeTargets.Property)] +public sealed class MagicRemoteOverrideAttribute : Attribute +{ + public MagicRemoteOverrideAttribute(bool allowed = true) => Allowed = allowed; + public bool Allowed { get; } +} + +[AttributeUsage(AttributeTargets.Property)] +public sealed class MagicSettingDescriptionAttribute : Attribute +{ + public MagicSettingDescriptionAttribute(string description) => Description = description; + public string Description { get; } +} +} diff --git a/MagicSettings/MagicSettings/Source.02.cs b/MagicSettings/MagicSettings/Source.02.cs new file mode 100644 index 0000000..a916a75 --- /dev/null +++ b/MagicSettings/MagicSettings/Source.02.cs @@ -0,0 +1,379 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings +{ +public sealed class MagicControlPlaneEndpointResolver : IMagicControlPlaneEndpointResolver +{ + public MagicResolvedControlPlaneEndpoint Resolve( + MagicSettingsOptions options, + JsonObject persistentDocument, + Uri? runtimeOverride = null) where TSettings : class, new() + { + if (runtimeOverride is not null) + { + return new(Validate(runtimeOverride, "runtime override", options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.RuntimeOverride); + } + + var environmentValue = Environment.GetEnvironmentVariable(options.ControlPlane.Bootstrap.EnvironmentVariableName); + if (!string.IsNullOrWhiteSpace(environmentValue)) + { + return new(ParseAbsolute(environmentValue, options.ControlPlane.Bootstrap.EnvironmentVariableName, options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.EnvironmentVariable); + } + + if (MagicJsonPath.TryGet(persistentDocument, options.ControlPlane.Bootstrap.PersistentSettingPath, out var node) + && node is JsonValue value + && value.TryGetValue(out var persistentValue) + && !string.IsNullOrWhiteSpace(persistentValue)) + { + return new(ParseAbsolute(persistentValue, options.ControlPlane.Bootstrap.PersistentSettingPath, options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.PersistentSettings); + } + + return options.ControlPlane.Bootstrap.CodeFallbackEndpoint is { } fallback + ? new(Validate(fallback, "code fallback", options.ControlPlane.Bootstrap.AllowInsecureHttp), MagicControlPlaneEndpointSource.CodeFallback) + : MagicResolvedControlPlaneEndpoint.None; + } + + private static Uri ParseAbsolute(string value, string source, bool allowInsecureHttp) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var endpoint)) + { + throw new InvalidOperationException($"Control-plane endpoint from '{source}' must be an absolute URI."); + } + + return Validate(endpoint, source, allowInsecureHttp); + } + + internal static Uri Validate(Uri endpoint, string source, bool allowInsecureHttp) + { + if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + && !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Control-plane endpoint from '{source}' must use HTTP or HTTPS."); + } + + if (string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !allowInsecureHttp + && !endpoint.IsLoopback) + { + throw new InvalidOperationException($"Control-plane endpoint from '{source}' must use HTTPS unless insecure HTTP is explicitly enabled or the endpoint is loopback."); + } + + return endpoint; + } +} + +internal sealed class MagicSettingsControlPlane : IMagicSettingsControlPlane where TSettings : class, new() +{ + private readonly MagicSettingsOptions _options; + private readonly MagicSettingsRuntime _runtime; + private readonly IMagicNodeAuthenticator _authenticator; + private readonly IMagicNodeIdentityManager _identityManager; + private readonly IMagicControlPlaneTransport _transport; + private readonly IMagicControlPlaneEndpointResolver _resolver; + private readonly MagicSettingsSchemaManifest _manifest; + private readonly SemaphoreSlim _gate = new(1, 1); + private MagicControlPlaneTrust? _trust; + private Uri? _runtimeOverrideEndpoint; + private MagicSettingsMigrationReport? _pendingMigrationReport; + private MagicIdentityContinuityProof? _pendingContinuityProof; + private long _lastRevision; + + public MagicSettingsControlPlane( + MagicSettingsOptions options, + MagicSettingsRuntime runtime, + IMagicNodeAuthenticator authenticator, + IMagicNodeIdentityManager identityManager, + IMagicControlPlaneTransport transport, + IMagicControlPlaneEndpointResolver resolver, + JsonObject persistentDocument, + MagicSettingsMigrationReport? migrationReport) + { + _options = options; + _runtime = runtime; + _authenticator = authenticator; + _identityManager = identityManager; + _transport = transport; + _resolver = resolver; + _manifest = MagicManifestBuilder.Build(options); + _pendingMigrationReport = migrationReport; + CurrentEndpoint = resolver.Resolve(options, persistentDocument); + State = CurrentEndpoint.Endpoint is null ? MagicControlPlaneState.Disabled : MagicControlPlaneState.Configured; + _identityManager.IdentityChanged += OnIdentityChanged; + } + + public MagicControlPlaneState State { get; private set; } + public MagicResolvedControlPlaneEndpoint CurrentEndpoint { get; private set; } + + internal bool TryGetActiveConnection(out Uri endpoint, out MagicControlPlaneTrust trust) + { + if (State == MagicControlPlaneState.Active && CurrentEndpoint.Endpoint is { } current && _trust is { } currentTrust) + { + endpoint = current; + trust = currentTrust; + return true; + } + + endpoint = null!; + trust = null!; + return false; + } + + public async ValueTask BootstrapAsync(CancellationToken cancellationToken) + { + if (!_options.ControlPlane.Bootstrap.ConnectOnStartup || CurrentEndpoint.Endpoint is null) + { + return; + } + + var trust = _options.ControlPlane.Bootstrap.Trust + ?? throw new InvalidOperationException("ConnectOnStartup requires an explicit MagicControlPlaneTrust configuration."); + await ConfigureResolvedAsync(CurrentEndpoint, trust, cancellationToken); + } + + public async ValueTask ConfigureAsync(Uri endpoint, MagicControlPlaneTrust trust, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(trust); + endpoint = MagicControlPlaneEndpointResolver.Validate(endpoint, "runtime override", _options.ControlPlane.Bootstrap.AllowInsecureHttp); + var resolved = new MagicResolvedControlPlaneEndpoint(endpoint, MagicControlPlaneEndpointSource.RuntimeOverride); + await ConfigureResolvedAsync(resolved, trust, cancellationToken); + _runtimeOverrideEndpoint = endpoint; + } + + public async ValueTask DisconnectAsync(bool clearRemoteOverrides = false, CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken); + try + { + State = MagicControlPlaneState.Disconnected; + _trust = null; + if (clearRemoteOverrides) + { + _lastRevision = 0; + _runtime.ClearRemote(); + } + } + finally + { + _gate.Release(); + } + } + + public async ValueTask RefreshAsync(CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken); + try + { + if (CurrentEndpoint.Endpoint is null || _trust is null) + { + return; + } + + await SynchronizeUnderLockAsync(CurrentEndpoint, _trust, cancellationToken); + } + finally + { + _gate.Release(); + } + } + + public async ValueTask ReevaluatePersistentEndpointAsync(JsonObject persistentDocument, CancellationToken cancellationToken) + { + if (!_options.ControlPlane.Bootstrap.WatchPersistentEndpoint) + { + return; + } + + var resolved = _resolver.Resolve(_options, persistentDocument, _runtimeOverrideEndpoint); + if (Equals(resolved.Endpoint, CurrentEndpoint.Endpoint)) + { + return; + } + + if (resolved.Endpoint is null) + { + await _gate.WaitAsync(cancellationToken); + try + { + CurrentEndpoint = MagicResolvedControlPlaneEndpoint.None; + _trust = null; + State = MagicControlPlaneState.Disabled; + if (!_options.ControlPlane.KeepLastKnownGoodDuringOutage) + { + _lastRevision = 0; + _runtime.ClearRemote(); + } + } + finally + { + _gate.Release(); + } + return; + } + + var trust = _options.ControlPlane.Bootstrap.Trust; + if (trust is null) + { + CurrentEndpoint = resolved; + State = MagicControlPlaneState.Configured; + return; + } + + await ConfigureResolvedAsync(resolved, trust, cancellationToken); + } + + private async ValueTask ConfigureResolvedAsync(MagicResolvedControlPlaneEndpoint resolved, MagicControlPlaneTrust trust, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + var previousEndpoint = CurrentEndpoint; + var previousTrust = _trust; + State = MagicControlPlaneState.Connecting; + try + { + await SynchronizeUnderLockAsync(resolved, trust, cancellationToken); + CurrentEndpoint = resolved; + _trust = trust; + } + catch + { + CurrentEndpoint = previousEndpoint; + _trust = previousTrust; + State = MagicControlPlaneState.Faulted; + throw; + } + } + finally + { + _gate.Release(); + } + } + + private async ValueTask SynchronizeUnderLockAsync(MagicResolvedControlPlaneEndpoint resolved, MagicControlPlaneTrust trust, CancellationToken cancellationToken) + { + var endpoint = resolved.Endpoint ?? throw new InvalidOperationException("A control-plane endpoint is required."); + var syncUri = new Uri(endpoint, "magicsettings/sync"); + var identity = await _authenticator.GetCurrentIdentityAsync(cancellationToken); + var signedPayloadHash = MagicSettingsSyncProof.ComputeBodySha256( + identity, + _manifest, + _lastRevision, + _pendingMigrationReport, + _pendingContinuityProof); + var proof = await _authenticator.CreateProofAsync( + new(trust.AuthorityId, "POST", syncUri, signedPayloadHash), + cancellationToken); + var response = await _transport.SynchronizeAsync( + endpoint, + trust, + new(identity, proof, _manifest, _lastRevision, _pendingMigrationReport, _pendingContinuityProof), + cancellationToken); + + State = response.State; + if (response.State == MagicControlPlaneState.Active) + { + _runtime.ApplyRemoteSnapshot(response.Snapshot, DateTimeOffset.UtcNow); + _lastRevision = response.Snapshot.Revision; + _pendingMigrationReport = null; + _pendingContinuityProof = null; + } + } + + private void OnIdentityChanged(object? sender, MagicIdentityChange change) + { + _lastRevision = 0; + _pendingContinuityProof = change.ContinuityProof; + if (change.Kind == MagicIdentityChangeKind.Reset || change.Kind == MagicIdentityChangeKind.RecoveredAfterLoss) + { + _runtime.ClearRemote(); + } + State = MagicControlPlaneState.PendingApproval; + } +} + +internal static class MagicManifestBuilder +{ + public static MagicSettingsSchemaManifest Build(MagicSettingsOptions options) where TSettings : class, new() + { + var discovered = new List(); + Visit(typeof(TSettings), string.Empty, discovered, new HashSet()); + var entries = discovered + .Select(item => item with + { + Sensitive = item.Sensitive || options.SensitivePaths.Contains(item.Path), + RemoteOverrideAllowed = item.RemoteOverrideAllowed && !string.Equals( + item.Path, + options.ControlPlane.Bootstrap.PersistentSettingPath, + StringComparison.OrdinalIgnoreCase) + }) + .ToList(); + var canonical = string.Join("\n", entries.OrderBy(static item => item.Path, StringComparer.Ordinal).Select(static item => $"{item.Path}|{item.Type}|{item.Nullable}|{item.Sensitive}|{item.RemoteOverrideAllowed}")); + var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); + return new(options.ApplicationId, options.ApplicationVersion, options.SchemaVersion, fingerprint, entries); + } + + private static void Visit(Type type, string path, ICollection entries, ISet stack) + { + if (!stack.Add(type)) + { + return; + } + + try + { + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public).Where(static property => property.CanRead)) + { + var propertyPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}:{property.Name}"; + var propertyType = Nullable.GetUnderlyingType(property.PropertyType) ?? property.PropertyType; + var sensitive = property.IsDefined(typeof(MagicSensitiveAttribute), inherit: true); + var remoteAllowed = property.GetCustomAttribute(inherit: true)?.Allowed ?? true; + var description = property.GetCustomAttribute(inherit: true)?.Description; + if (IsLeaf(propertyType)) + { + entries.Add(new( + propertyPath, + propertyType.FullName ?? propertyType.Name, + Nullable.GetUnderlyingType(property.PropertyType) is not null || !property.PropertyType.IsValueType, + sensitive, + remoteAllowed, + description)); + } + else if (!typeof(System.Collections.IEnumerable).IsAssignableFrom(propertyType) || propertyType == typeof(string)) + { + Visit(propertyType, propertyPath, entries, stack); + } + else + { + entries.Add(new(propertyPath, propertyType.FullName ?? propertyType.Name, true, sensitive, remoteAllowed, description)); + } + } + } + finally + { + stack.Remove(type); + } + } + + private static bool IsLeaf(Type type) + => type.IsPrimitive || type.IsEnum || type == typeof(string) || type == typeof(decimal) || type == typeof(Guid) || type == typeof(Uri) || type == typeof(DateTime) || type == typeof(DateTimeOffset) || type == typeof(TimeSpan); +} +} diff --git a/MagicSettings/MagicSettings/Source.03.cs b/MagicSettings/MagicSettings/Source.03.cs new file mode 100644 index 0000000..4751f52 --- /dev/null +++ b/MagicSettings/MagicSettings/Source.03.cs @@ -0,0 +1,345 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings +{ +internal sealed record MagicSettingsDocumentResult(JsonObject Document, MagicSettingsMigrationReport? MigrationReport, bool Changed); + +internal static class MagicSettingsDocumentStore +{ + private const string MetadataProperty = "$magicSettings"; + + public static async ValueTask LoadAndReconcileAsync( + string path, + MagicSettingsOptions options, + bool forceRegenerate, + CancellationToken cancellationToken) where TSettings : class, new() + { + Directory.CreateDirectory(Path.GetDirectoryName(path) ?? AppContext.BaseDirectory); + var template = JsonSerializer.SerializeToNode(options.Template, options.Json) as JsonObject + ?? throw new InvalidOperationException("The settings template must serialize as a JSON object."); + + JsonObject document; + var existed = File.Exists(path); + if (!existed || forceRegenerate) + { + document = (JsonObject)template.DeepClone(); + } + else + { + await using var stream = File.OpenRead(path); + document = await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) as JsonObject + ?? throw new InvalidOperationException($"Settings file '{path}' must contain a JSON object."); + } + + var currentVersion = GetSchemaVersion(document); + MagicSettingsMigrationReport? report = null; + if (existed && !forceRegenerate && currentVersion < options.SchemaVersion) + { + report = MagicMigrationRunner.Run(document, currentVersion, options.SchemaVersion, options.Migrations); + } + + var strict = options.Failures.StrictDevelopmentMode && IsDevelopment(options.Environment); + var changed = !existed || forceRegenerate; + changed |= ReconcileObject(document, template, string.Empty, options, strict); + changed |= WriteMetadata(document, options); + + if (changed) + { + await WriteAtomicAsync(path, document, options.Json, cancellationToken); + } + + return new(document, report, changed); + } + + public static async ValueTask ReadAsync(string path, CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(path); + return await JsonNode.ParseAsync(stream, cancellationToken: cancellationToken) as JsonObject + ?? throw new InvalidOperationException($"Settings file '{path}' must contain a JSON object."); + } + + private static bool ReconcileObject(JsonObject existing, JsonObject template, string path, MagicSettingsOptions options, bool strict) + where TSettings : class, new() + { + var changed = false; + foreach (var pair in template) + { + var childPath = string.IsNullOrEmpty(path) ? pair.Key : $"{path}:{pair.Key}"; + if (!MagicJsonPath.TryGetProperty(existing, pair.Key, out var actualPropertyName, out var existingValue)) + { + existing[pair.Key] = pair.Value?.DeepClone(); + changed = true; + continue; + } + + if (existingValue is JsonObject existingObject && pair.Value is JsonObject templateObject) + { + changed |= ReconcileObject(existingObject, templateObject, childPath, options, strict); + continue; + } + + if (existingValue is JsonArray existingArray && pair.Value is JsonArray templateArray && !JsonNode.DeepEquals(existingArray, templateArray)) + { + if (!options.ArrayPolicies.TryGetValue(childPath, out var policy)) + { + if (strict) + { + throw new InvalidOperationException($"Array reconciliation for '{childPath}' is ambiguous. Configure an explicit MagicArrayMergePolicy."); + } + + policy = MagicArrayMergePolicy.PreserveExisting; + } + + changed |= ApplyArrayPolicy(existing, actualPropertyName ?? pair.Key, existingArray, templateArray, policy); + } + } + + return changed; + } + + private static bool ApplyArrayPolicy(JsonObject parent, string propertyName, JsonArray existing, JsonArray template, MagicArrayMergePolicy policy) + { + switch (policy) + { + case MagicArrayMergePolicy.PreserveExisting: + return false; + case MagicArrayMergePolicy.ReplaceWithTemplate: + parent[propertyName] = template.DeepClone(); + return true; + case MagicArrayMergePolicy.AppendMissing: + case MagicArrayMergePolicy.Union: + var changed = false; + foreach (var item in template) + { + if (existing.Any(candidate => JsonNode.DeepEquals(candidate, item))) + { + continue; + } + + existing.Add(item?.DeepClone()); + changed = true; + } + + return changed; + default: + throw new ArgumentOutOfRangeException(nameof(policy), policy, null); + } + } + + private static int GetSchemaVersion(JsonObject document) + => document[MetadataProperty]?["schemaVersion"]?.GetValue() ?? 1; + + private static bool WriteMetadata(JsonObject document, MagicSettingsOptions options) where TSettings : class, new() + { + var metadata = document[MetadataProperty] as JsonObject ?? new JsonObject(); + var before = metadata.ToJsonString(); + metadata["schemaVersion"] = options.SchemaVersion; + metadata["applicationId"] = options.ApplicationId; + metadata["generatedBy"] = "MagicSettings"; + document[MetadataProperty] = metadata; + return !string.Equals(before, metadata.ToJsonString(), StringComparison.Ordinal); + } + + private static async ValueTask WriteAtomicAsync(string path, JsonObject document, JsonSerializerOptions options, CancellationToken cancellationToken) + { + var temporary = $"{path}.{Guid.NewGuid():N}.tmp"; + await File.WriteAllTextAsync(temporary, document.ToJsonString(options), cancellationToken); + if (File.Exists(path)) + { + var backup = $"{path}.bak"; + File.Copy(path, backup, overwrite: true); + File.Move(temporary, path, overwrite: true); + } + else + { + File.Move(temporary, path); + } + } + + private static bool IsDevelopment(string environment) + => environment.Equals("Development", StringComparison.OrdinalIgnoreCase) + || environment.Equals("Local", StringComparison.OrdinalIgnoreCase) + || environment.Equals("Test", StringComparison.OrdinalIgnoreCase); +} +} +namespace MagicSettings +{ +internal sealed class MagicSettingsHostedService : BackgroundService where TSettings : class, new() +{ + private readonly MagicSettingsOptions _options; + private readonly MagicSettingsRuntime _runtime; + private readonly MagicSettingsControlPlane _controlPlane; + private readonly ILogger> _logger; + private readonly string _settingsPath; + private DateTime _lastWriteUtc; + private DateTimeOffset _nextRemotePoll; + + public MagicSettingsHostedService( + MagicSettingsOptions options, + MagicSettingsRuntime runtime, + MagicSettingsControlPlane controlPlane, + ILogger> logger, + string settingsPath) + { + _options = options; + _runtime = runtime; + _controlPlane = controlPlane; + _logger = logger; + _settingsPath = settingsPath; + _lastWriteUtc = File.Exists(settingsPath) ? File.GetLastWriteTimeUtc(settingsPath) : DateTime.MinValue; + _nextRemotePoll = DateTimeOffset.UtcNow.Add(options.ControlPlane.PollInterval); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await _controlPlane.BootstrapAsync(stoppingToken); + } + catch (Exception exception) + { + _logger.LogError(exception, "MagicSettings could not establish the bootstrap control-plane connection. Local configuration remains active."); + } + + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(500)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + _runtime.RefreshRemoteExpirations(DateTimeOffset.UtcNow); + if (_options.ReloadOnChange) + { + await CheckPersistentFileAsync(stoppingToken); + } + + if (DateTimeOffset.UtcNow >= _nextRemotePoll) + { + await PollRemoteAsync(stoppingToken); + _nextRemotePoll = DateTimeOffset.UtcNow.Add(JitteredPollInterval()); + } + } + } + + private async ValueTask CheckPersistentFileAsync(CancellationToken cancellationToken) + { + if (!File.Exists(_settingsPath)) + { + return; + } + + var currentWrite = File.GetLastWriteTimeUtc(_settingsPath); + if (currentWrite <= _lastWriteUtc) + { + return; + } + + await Task.Delay(_options.ReloadDebounce, cancellationToken); + try + { + await _runtime.ReloadPersistentAsync(cancellationToken); + var document = await MagicSettingsDocumentStore.ReadAsync(_settingsPath, cancellationToken); + await _controlPlane.ReevaluatePersistentEndpointAsync(document, cancellationToken); + _lastWriteUtc = currentWrite; + } + catch (Exception exception) + { + _logger.LogError(exception, "MagicSettings rejected an invalid runtime reload and retained the last known good snapshot."); + } + } + + private async ValueTask PollRemoteAsync(CancellationToken cancellationToken) + { + try + { + await _controlPlane.RefreshAsync(cancellationToken); + } + catch (Exception exception) + { + _logger.LogWarning(exception, "MagicSettings could not refresh remote overrides. The last known good remote snapshot remains active."); + } + } + + private TimeSpan JitteredPollInterval() + { + var jitter = _options.ControlPlane.PollJitter; + if (jitter <= TimeSpan.Zero) + { + return _options.ControlPlane.PollInterval; + } + + var milliseconds = Random.Shared.NextDouble() * jitter.TotalMilliseconds * 2 - jitter.TotalMilliseconds; + return _options.ControlPlane.PollInterval + TimeSpan.FromMilliseconds(milliseconds); + } +} +} +namespace MagicSettings +{ +public sealed class MagicNodeAuthenticationHandler : DelegatingHandler +{ + private readonly IMagicNodeAuthenticator _authenticator; + private readonly string _audience; + + public MagicNodeAuthenticationHandler(IMagicNodeAuthenticator authenticator, string audience) + { + _authenticator = authenticator; + _audience = audience; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request.RequestUri); + var body = request.Content is null + ? Array.Empty() + : await request.Content.ReadAsByteArrayAsync(cancellationToken); + + if (request.Content is not null) + { + var replacement = new ByteArrayContent(body); + foreach (var header in request.Content.Headers) + { + replacement.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + request.Content = replacement; + } + + var proof = await _authenticator.CreateProofAsync( + new( + _audience, + request.Method.Method, + request.RequestUri, + MagicHash.Sha256Hex(body)), + cancellationToken); + + request.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(proof)); + request.Headers.TryAddWithoutValidation("X-Magic-Node-Id", proof.NodeId.ToString("D")); + request.Headers.TryAddWithoutValidation("X-Magic-Credential-Id", proof.CredentialId.ToString("D")); + return await base.SendAsync(request, cancellationToken); + } +} + +public static class MagicHttpClientBuilderExtensions +{ + public static IHttpClientBuilder AddMagicNodeAuthentication(this IHttpClientBuilder builder, string audience) + { + ArgumentException.ThrowIfNullOrWhiteSpace(audience); + return builder.AddHttpMessageHandler(serviceProvider => + new MagicNodeAuthenticationHandler(serviceProvider.GetRequiredService(), audience)); + } +} +} diff --git a/MagicSettings/MagicSettings/Source.04.cs b/MagicSettings/MagicSettings/Source.04.cs new file mode 100644 index 0000000..897ebf7 --- /dev/null +++ b/MagicSettings/MagicSettings/Source.04.cs @@ -0,0 +1,423 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings +{ +public sealed class HttpMagicControlPlaneTransport : IMagicControlPlaneTransport, IMagicSecretTransport, IDisposable +{ + private readonly HttpClient _systemTlsClient; + private readonly ConcurrentDictionary _pinnedClients = new(StringComparer.OrdinalIgnoreCase); + private readonly JsonSerializerOptions _json; + + public HttpMagicControlPlaneTransport(TimeSpan? timeout = null, JsonSerializerOptions? json = null) + { + _systemTlsClient = CreateClient(certificateValidator: null, timeout); + _json = json ?? new JsonSerializerOptions(JsonSerializerDefaults.Web); + } + + public async ValueTask SynchronizeAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSettingsSyncRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(trust); + ArgumentNullException.ThrowIfNull(request); + + var client = SelectClient(trust); + + var syncUri = new Uri(endpoint, "magicsettings/sync"); + using var message = new HttpRequestMessage(HttpMethod.Post, syncUri) + { + Content = new StringContent(JsonSerializer.Serialize(request, _json), Encoding.UTF8, "application/json") + }; + message.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(request.Proof)); + using var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken) + ?? throw new InvalidOperationException("The control plane returned an empty synchronization response."); + } + + + public async ValueTask ResolveSecretAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSecretRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(trust); + ArgumentNullException.ThrowIfNull(request); + + var client = SelectClient(trust); + var requestUri = new Uri(endpoint, "magicsettings/secrets/resolve"); + using var message = new HttpRequestMessage(HttpMethod.Post, requestUri) + { + Content = new StringContent(JsonSerializer.Serialize(request, _json), Encoding.UTF8, "application/json") + }; + message.Headers.Authorization = new AuthenticationHeaderValue("MagicNode", MagicNodeProofCodec.Encode(request.Proof)); + using var response = await client.SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken) + ?? throw new InvalidOperationException("The control plane returned an empty secret response."); + } + + public void Dispose() + { + _systemTlsClient.Dispose(); + foreach (var client in _pinnedClients.Values) + { + client.Dispose(); + } + } + + private HttpClient SelectClient(MagicControlPlaneTrust trust) + => trust.Mode switch + { + MagicControlPlaneTrustMode.SystemTls => _systemTlsClient, + MagicControlPlaneTrustMode.PinnedPublicKey => GetPinnedClient(trust), + _ => throw new ArgumentOutOfRangeException(nameof(trust), trust.Mode, "Unsupported control-plane trust mode.") + }; + + private HttpClient GetPinnedClient(MagicControlPlaneTrust trust) + { + var expected = NormalizeFingerprint(trust.PinnedPublicKeyFingerprint + ?? throw new InvalidOperationException("PinnedPublicKey trust requires a public-key fingerprint.")); + return _pinnedClients.GetOrAdd(expected, fingerprint => CreateClient( + (_, certificate, _, _) => certificate is not null && FixedTimeEquals(PublicKeyFingerprint(certificate), fingerprint), + _systemTlsClient.Timeout)); + } + + private static HttpClient CreateClient( + Func? certificateValidator, + TimeSpan? timeout) + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = false + }; + if (certificateValidator is not null) + { + handler.ServerCertificateCustomValidationCallback = certificateValidator; + } + + return new HttpClient(handler, disposeHandler: true) + { + Timeout = timeout ?? TimeSpan.FromSeconds(30) + }; + } + + private static string PublicKeyFingerprint(X509Certificate2 certificate) + => Convert.ToHexString(SHA256.HashData(certificate.GetPublicKey())).ToLowerInvariant(); + + private static string NormalizeFingerprint(string value) + => value.Replace(":", string.Empty, StringComparison.Ordinal).Trim().ToLowerInvariant(); + + private static bool FixedTimeEquals(string actual, string expected) + { + var actualBytes = Encoding.ASCII.GetBytes(actual); + var expectedBytes = Encoding.ASCII.GetBytes(expected); + return actualBytes.Length == expectedBytes.Length + && CryptographicOperations.FixedTimeEquals(actualBytes, expectedBytes); + } +} +} +namespace MagicSettings +{ +public sealed class FileMagicNodeIdentityStore : IMagicNodeIdentityStore +{ + private readonly string _path; + private readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + public FileMagicNodeIdentityStore(string path) => _path = Path.GetFullPath(path); + + public async ValueTask LoadAsync(CancellationToken cancellationToken = default) + { + if (!File.Exists(_path)) + { + return null; + } + + await using var stream = File.OpenRead(_path); + return await JsonSerializer.DeserializeAsync(stream, _json, cancellationToken); + } + + public async ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default) + { + Directory.CreateDirectory(Path.GetDirectoryName(_path) ?? AppContext.BaseDirectory); + var temporary = $"{_path}.{Guid.NewGuid():N}.tmp"; + await using (var stream = File.Create(temporary)) + { + await JsonSerializer.SerializeAsync(stream, identity, _json, cancellationToken); + await stream.FlushAsync(cancellationToken); + } + + TryRestrictPermissions(temporary); + File.Move(temporary, _path, overwrite: true); + TryRestrictPermissions(_path); + } + + public ValueTask DeleteAsync(CancellationToken cancellationToken = default) + { + if (File.Exists(_path)) + { + File.Delete(_path); + } + + return ValueTask.CompletedTask; + } + + private static void TryRestrictPermissions(string path) + { + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite); + } + } +} + +internal static class MagicIdentityCryptography +{ + public const string ProofVersion = "MAGICSETTINGS-PROOF-V1"; + public const string SignatureAlgorithm = "ECDSA_P256_SHA256_P1363"; + + public static MagicStoredNodeIdentity Create(Guid? nodeId = null) + { + using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); + return new( + nodeId ?? Guid.NewGuid(), + Guid.NewGuid(), + Convert.ToBase64String(key.ExportSubjectPublicKeyInfo()), + Convert.ToBase64String(key.ExportPkcs8PrivateKey()), + DateTimeOffset.UtcNow); + } + + public static MagicNodeIdentityDescriptor Describe(MagicStoredNodeIdentity identity) + => new( + identity.NodeId, + identity.CredentialId, + MagicCredentialKind.EcdsaP256, + SignatureAlgorithm, + identity.PublicKey, + Fingerprint(identity.PublicKey), + identity.CreatedUtc); + + public static string Sign(MagicStoredNodeIdentity identity, string canonical) + { + using var key = ECDsa.Create(); + key.ImportPkcs8PrivateKey(Convert.FromBase64String(identity.PrivateKey), out _); + var signature = key.SignData( + Encoding.UTF8.GetBytes(canonical), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + return Convert.ToBase64String(signature); + } + + public static bool Verify(string publicKey, string canonical, string signature) + { + using var key = ECDsa.Create(); + key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKey), out _); + return key.VerifyData( + Encoding.UTF8.GetBytes(canonical), + Convert.FromBase64String(signature), + HashAlgorithmName.SHA256, + DSASignatureFormat.IeeeP1363FixedFieldConcatenation); + } + + public 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(System.Globalization.CultureInfo.InvariantCulture), + proof.ExpiresUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + proof.Nonce); + + public static string CanonicalizeContinuity(MagicNodeIdentityDescriptor previous, MagicNodeIdentityDescriptor current, DateTimeOffset issuedUtc, string nonce) + => string.Join('\n', + "MAGICSETTINGS-IDENTITY-CONTINUITY-V1", + previous.NodeId.ToString("D"), + previous.CredentialId.ToString("D"), + current.CredentialId.ToString("D"), + current.PublicKey, + issuedUtc.ToUnixTimeMilliseconds().ToString(System.Globalization.CultureInfo.InvariantCulture), + nonce); + + public static string NormalizeTarget(Uri uri) + => uri.IsAbsoluteUri + ? uri.GetComponents(UriComponents.SchemeAndServer | UriComponents.PathAndQuery, UriFormat.UriEscaped) + : uri.OriginalString; + + public static string Fingerprint(string publicKey) + => Convert.ToHexString(SHA256.HashData(Convert.FromBase64String(publicKey))).ToLowerInvariant(); +} + +public sealed class MagicNodeIdentityManager : IMagicNodeIdentityManager, IMagicNodeAuthenticator +{ + private readonly IMagicNodeIdentityStore _store; + private readonly SemaphoreSlim _gate = new(1, 1); + private MagicStoredNodeIdentity? _current; + + public MagicNodeIdentityManager(IMagicNodeIdentityStore store) => _store = store; + + public event EventHandler? IdentityChanged; + + public async ValueTask GetCurrentAsync(CancellationToken cancellationToken = default) + => MagicIdentityCryptography.Describe(await GetStoredAsync(cancellationToken)); + + public ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default) + => GetCurrentAsync(cancellationToken); + + public async ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(request.Audience); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Method); + ArgumentNullException.ThrowIfNull(request.Uri); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BodySha256); + + var identity = await GetStoredAsync(cancellationToken); + var issued = DateTimeOffset.UtcNow; + var validFor = request.ValidFor ?? TimeSpan.FromMinutes(1); + if (validFor <= TimeSpan.Zero || validFor > TimeSpan.FromMinutes(5)) + { + throw new ArgumentOutOfRangeException(nameof(request), "Authentication proofs must be valid for more than zero and no longer than five minutes."); + } + + var unsigned = new MagicAuthenticationProof( + MagicIdentityCryptography.ProofVersion, + identity.NodeId, + identity.CredentialId, + request.Audience, + request.Method.ToUpperInvariant(), + MagicIdentityCryptography.NormalizeTarget(request.Uri), + request.BodySha256.ToLowerInvariant(), + issued, + issued.Add(validFor), + MagicNodeProofCodec.Base64UrlEncode(RandomNumberGenerator.GetBytes(24)), + string.Empty); + + return unsigned with { Signature = MagicIdentityCryptography.Sign(identity, MagicIdentityCryptography.Canonicalize(unsigned)) }; + } + + public async ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(reason); + await _gate.WaitAsync(cancellationToken); + try + { + var previous = await LoadOrCreateUnderLockAsync(cancellationToken); + var next = MagicIdentityCryptography.Create(previous.NodeId); + var previousDescriptor = MagicIdentityCryptography.Describe(previous); + var nextDescriptor = MagicIdentityCryptography.Describe(next); + var issued = DateTimeOffset.UtcNow; + var nonce = MagicNodeProofCodec.Base64UrlEncode(RandomNumberGenerator.GetBytes(24)); + var canonical = MagicIdentityCryptography.CanonicalizeContinuity(previousDescriptor, nextDescriptor, issued, nonce); + var continuity = new MagicIdentityContinuityProof(previousDescriptor, nextDescriptor, issued, nonce, MagicIdentityCryptography.Sign(previous, canonical)); + await _store.SaveAsync(next, cancellationToken); + _current = next; + var change = new MagicIdentityChange(MagicIdentityChangeKind.Rotated, nextDescriptor, previousDescriptor, continuity, reason); + IdentityChanged?.Invoke(this, change); + return change; + } + finally + { + _gate.Release(); + } + } + + public async ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + if (!request.ConfirmDestructiveReset) + { + throw new InvalidOperationException("Identity reset is destructive and requires ConfirmDestructiveReset=true."); + } + + await _gate.WaitAsync(cancellationToken); + try + { + var previous = await _store.LoadAsync(cancellationToken); + var next = MagicIdentityCryptography.Create(); + await _store.SaveAsync(next, cancellationToken); + _current = next; + var change = new MagicIdentityChange( + MagicIdentityChangeKind.Reset, + MagicIdentityCryptography.Describe(next), + previous is null ? null : MagicIdentityCryptography.Describe(previous), + null, + request.Reason); + IdentityChanged?.Invoke(this, change); + return change; + } + finally + { + _gate.Release(); + } + } + + private async ValueTask GetStoredAsync(CancellationToken cancellationToken) + { + if (_current is not null) + { + return _current; + } + + await _gate.WaitAsync(cancellationToken); + try + { + return await LoadOrCreateUnderLockAsync(cancellationToken); + } + finally + { + _gate.Release(); + } + } + + private async ValueTask LoadOrCreateUnderLockAsync(CancellationToken cancellationToken) + { + if (_current is not null) + { + return _current; + } + + _current = await _store.LoadAsync(cancellationToken); + if (_current is null) + { + _current = MagicIdentityCryptography.Create(); + await _store.SaveAsync(_current, cancellationToken); + } + + return _current; + } +} + +public static class MagicHash +{ + public static string Sha256Hex(ReadOnlySpan value) => Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant(); + public static string EmptySha256 { get; } = Sha256Hex(Array.Empty()); +} +} diff --git a/MagicSettings/MagicSettings/Source.05.cs b/MagicSettings/MagicSettings/Source.05.cs new file mode 100644 index 0000000..de23aef --- /dev/null +++ b/MagicSettings/MagicSettings/Source.05.cs @@ -0,0 +1,414 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings +{ +public static class MagicSettingsInitializationExtensions +{ + public static async ValueTask AddMagicSettingsAsync( + this IHostApplicationBuilder builder, + string[]? args = null, + Action>? configure = null, + CancellationToken cancellationToken = default) + where TSettings : class, new() + { + ArgumentNullException.ThrowIfNull(builder); + var options = new MagicSettingsOptions(); + configure?.Invoke(options); + options.Environment = MagicSettingsEnvironmentResolver.Resolve(options.Environment, builder.Environment.EnvironmentName); + if (options.EnvironmentProfiles.TryGetValue(options.Environment, out var environmentProfile)) + { + environmentProfile(options.Template); + } + + var command = MagicSettingsCommandLine.Parse(args ?? Array.Empty()); + var path = MagicSettingsPathResolver.Resolve(options); + var documentResult = await MagicSettingsDocumentStore.LoadAndReconcileAsync( + path, + options, + command.ForceGenerate, + cancellationToken); + + if (command.PrintPath) + { + Console.Out.WriteLine(path); + } + + var identityPath = MagicIdentityPathResolver.Resolve(path, options.IdentityPath, options.IdentityFileName); + var identityStore = options.IdentityStore ?? new FileMagicNodeIdentityStore(identityPath); + var identityManager = new MagicNodeIdentityManager(identityStore); + await identityManager.GetCurrentAsync(cancellationToken); + + var configurationProvider = new MagicSettingsConfigurationProvider(); + var runtime = new MagicSettingsRuntime(options, configurationProvider, path); + await runtime.InitializeAsync(documentResult.Document, cancellationToken); + + var endpointResolver = options.ControlPlaneEndpointResolver ?? new MagicControlPlaneEndpointResolver(); + var transport = options.ControlPlaneTransport ?? new HttpMagicControlPlaneTransport(); + var controlPlane = new MagicSettingsControlPlane( + options, + runtime, + identityManager, + identityManager, + transport, + endpointResolver, + documentResult.Document, + documentResult.MigrationReport); + + builder.Configuration.Add(new MagicSettingsConfigurationSource(configurationProvider)); + builder.Services.AddSingleton(options); + builder.Services.AddSingleton>(runtime); + builder.Services.AddSingleton(runtime); + builder.Services.AddSingleton(identityManager); + builder.Services.AddSingleton(identityManager); + builder.Services.AddSingleton(controlPlane); + builder.Services.AddSingleton(controlPlane); + builder.Services.AddSingleton(endpointResolver); + builder.Services.AddSingleton(transport); + if (transport is IMagicSecretTransport secretTransport) + { + builder.Services.AddSingleton(secretTransport); + builder.Services.AddSingleton( + new MagicControlPlaneSecretProvider(controlPlane, identityManager, secretTransport, options)); + } + builder.Services.AddSingleton(new MagicSettingsRuntimeRegistration(path)); + builder.Services.AddHostedService(serviceProvider => + new MagicSettingsHostedService( + options, + runtime, + controlPlane, + serviceProvider.GetRequiredService>>(), + path)); + builder.Services.AddOptions().Bind(builder.Configuration); + + var shouldExit = command.GenerateOnly || command.ValidateOnly || command.PrintPath; + return new(shouldExit, 0, path, options.Environment); + } +} + +public sealed record MagicSettingsRuntimeRegistration(string SettingsPath); + +internal sealed record MagicSettingsCommandLine(bool GenerateOnly, bool ForceGenerate, bool ValidateOnly, bool PrintPath) +{ + public static MagicSettingsCommandLine Parse(IEnumerable args) + { + var set = args.ToHashSet(StringComparer.OrdinalIgnoreCase); + var force = set.Contains("--magic-settings-force-generate"); + return new( + set.Contains("--magic-settings-generate") || force, + force, + set.Contains("--magic-settings-validate"), + set.Contains("--magic-settings-print-path")); + } +} +} +namespace MagicSettings +{ +internal static class MagicJsonPath +{ + public static bool TryGet(JsonObject root, string path, out JsonNode? value) + { + value = root; + foreach (var segment in Split(path)) + { + if (value is not JsonObject obj || !TryGetProperty(obj, segment, out _, out value)) + { + value = null; + return false; + } + } + + return true; + } + + public static void Set(JsonObject root, string path, JsonNode? value) + { + var segments = Split(path); + if (segments.Length == 0) + { + throw new ArgumentException("A setting path is required.", nameof(path)); + } + + var current = root; + for (var index = 0; index < segments.Length - 1; index++) + { + var segment = segments[index]; + if (!TryGetProperty(current, segment, out var actualName, out var existing) || existing is not JsonObject child) + { + child = new JsonObject(); + current[actualName ?? segment] = child; + } + + current = child; + } + + if (TryGetProperty(current, segments[^1], out var finalName, out _)) + { + current[finalName!] = value?.DeepClone(); + } + else + { + current[segments[^1]] = value?.DeepClone(); + } + } + + public static bool TryTake(JsonObject root, string path, out JsonNode? value) + { + value = null; + var segments = Split(path); + if (segments.Length == 0) + { + return false; + } + + var current = root; + for (var index = 0; index < segments.Length - 1; index++) + { + if (!TryGetProperty(current, segments[index], out _, out var existing) || existing is not JsonObject child) + { + return false; + } + + current = child; + } + + if (!TryGetProperty(current, segments[^1], out var actualName, out value)) + { + return false; + } + + current.Remove(actualName!); + return true; + } + + public static bool TryGetProperty(JsonObject obj, string name, out string? actualName, out JsonNode? value) + { + if (obj.TryGetPropertyValue(name, out value)) + { + actualName = name; + return true; + } + + foreach (var pair in obj) + { + if (string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + actualName = pair.Key; + value = pair.Value; + return true; + } + } + + actualName = null; + value = null; + return false; + } + + public static string[] Split(string path) + => path.Split(new[] { ':', '.' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); +} + +internal static class MagicJsonFlattener +{ + public static Dictionary Flatten(JsonObject root) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + Visit(root, null, result); + return result; + } + + private static void Visit(JsonNode? node, string? path, IDictionary result) + { + switch (node) + { + case JsonObject obj: + foreach (var pair in obj) + { + if (pair.Key == "$magicSettings") + { + continue; + } + + Visit(pair.Value, path is null ? pair.Key : $"{path}:{pair.Key}", result); + } + break; + case JsonArray array: + for (var index = 0; index < array.Count; index++) + { + Visit(array[index], $"{path}:{index}", result); + } + break; + case JsonValue value when path is not null: + result[path] = ToConfigurationString(value); + break; + case null when path is not null: + result[path] = null; + break; + } + } + + private static string? ToConfigurationString(JsonValue value) + { + if (value.TryGetValue(out var text)) return text; + if (value.TryGetValue(out var boolean)) return boolean ? "true" : "false"; + if (value.TryGetValue(out var integer)) return integer.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var longInteger)) return longInteger.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var number)) return number.ToString(CultureInfo.InvariantCulture); + if (value.TryGetValue(out var decimalNumber)) return decimalNumber.ToString(CultureInfo.InvariantCulture); + return value.ToJsonString(); + } +} + +internal static class MagicEnvironmentOverrides +{ + public static Dictionary Read(string prefix) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + if (entry.Key is not string name || !name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var path = name[prefix.Length..].Replace("__", ":", StringComparison.Ordinal); + result[path] = entry.Value?.ToString(); + } + + return result; + } +} +} +namespace MagicSettings +{ +public sealed class MagicMigrationContext +{ + private readonly List _safeOperations = new(); + private readonly List _reviewItems = new(); + + public IReadOnlyList SafeOperations => _safeOperations; + public IReadOnlyList ReviewItems => _reviewItems; + + public void RecordSafe(string operation) => _safeOperations.Add(operation); + + public void RequireReview(string path, string operation, string reason, string? proposedPath = null, MagicMigrationReviewSeverity severity = MagicMigrationReviewSeverity.Warning) + => _reviewItems.Add(new(path, operation, severity, reason, proposedPath)); + + public void Rename(JsonObject document, string from, string to, bool remoteSafeProjection = true) + { + if (!MagicJsonPath.TryTake(document, from, out var value)) + { + return; + } + + if (MagicJsonPath.TryGet(document, to, out _)) + { + throw new InvalidOperationException($"Cannot rename '{from}' to '{to}' because the destination already exists."); + } + + MagicJsonPath.Set(document, to, value); + RecordSafe($"rename:{from}->{to}"); + if (!remoteSafeProjection) + { + RequireReview(from, "rename", "The remote record should retain the original value until reviewed.", to); + } + } + + public void Transform( + JsonObject document, + string path, + Func transform, + string description, + bool remoteSafeProjection = false) + { + ArgumentNullException.ThrowIfNull(transform); + if (!MagicJsonPath.TryGet(document, path, out var current)) + { + return; + } + + MagicJsonPath.Set(document, path, transform(current?.DeepClone())); + RecordSafe($"transform:{path}:{description}"); + if (!remoteSafeProjection) + { + RequireReview(path, "transform", description); + } + } + + public void SetIfMissing(JsonObject document, string path, JsonNode? value) + { + if (MagicJsonPath.TryGet(document, path, out _)) + { + return; + } + + MagicJsonPath.Set(document, path, value); + RecordSafe($"set-if-missing:{path}"); + } + + public void Remove(JsonObject document, string path, string reason) + { + if (MagicJsonPath.TryTake(document, path, out _)) + { + RecordSafe($"local-remove:{path}"); + RequireReview(path, "remove", reason, severity: MagicMigrationReviewSeverity.Destructive); + } + } +} + +internal static class MagicMigrationRunner +{ + public static MagicSettingsMigrationReport? Run(JsonObject document, int currentVersion, int targetVersion, IEnumerable migrations) + { + if (currentVersion > targetVersion) + { + throw new InvalidOperationException($"Downgrading settings from schema {currentVersion} to {targetVersion} is not supported."); + } + + if (currentVersion == targetVersion) + { + return null; + } + + var ordered = migrations.OrderBy(static migration => migration.FromVersion).ToList(); + var context = new MagicMigrationContext(); + var version = currentVersion; + while (version < targetVersion) + { + var migration = ordered.SingleOrDefault(item => item.FromVersion == version) + ?? throw new InvalidOperationException($"No migration exists from schema version {version}."); + if (migration.ToVersion <= version) + { + throw new InvalidOperationException($"Migration {migration.GetType().Name} does not advance the schema version."); + } + + migration.Apply(document, context); + version = migration.ToVersion; + } + + if (version != targetVersion) + { + throw new InvalidOperationException($"Migration chain ended at schema version {version}, expected {targetVersion}."); + } + + return new(currentVersion, targetVersion, context.SafeOperations.ToArray(), context.ReviewItems.ToArray()); + } +} +} diff --git a/MagicSettings/MagicSettings/Source.06.cs b/MagicSettings/MagicSettings/Source.06.cs new file mode 100644 index 0000000..5cfe2f9 --- /dev/null +++ b/MagicSettings/MagicSettings/Source.06.cs @@ -0,0 +1,161 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings +{ +public enum MagicFailureAction +{ + StopStartup, + WarnAndContinue, + KeepLastKnownGood +} + +public enum MagicArrayMergePolicy +{ + PreserveExisting, + ReplaceWithTemplate, + AppendMissing, + Union +} + +public sealed class MagicSettingsFailurePolicy +{ + public bool StrictDevelopmentMode { get; set; } = true; + public MagicFailureAction AmbiguousArrayInProduction { get; set; } = MagicFailureAction.WarnAndContinue; + public MagicFailureAction RuntimeReloadFailure { get; set; } = MagicFailureAction.KeepLastKnownGood; +} + +public sealed class MagicControlPlaneBootstrapOptions +{ + public string EnvironmentVariableName { get; set; } = "MAGICSETTINGS_CONTROL_PLANE_ENDPOINT"; + public string PersistentSettingPath { get; set; } = "MagicSettings:ControlPlane:Endpoint"; + public Uri? CodeFallbackEndpoint { get; set; } + public MagicControlPlaneTrust? Trust { get; set; } + public bool ConnectOnStartup { get; set; } + public bool WatchPersistentEndpoint { get; set; } = true; + public bool AllowInsecureHttp { get; set; } +} + +public sealed class MagicControlPlaneOptions +{ + public MagicControlPlaneBootstrapOptions Bootstrap { get; } = new(); + public TimeSpan PollInterval { get; set; } = TimeSpan.FromSeconds(90); + public TimeSpan PollJitter { get; set; } = TimeSpan.FromSeconds(30); + public bool KeepLastKnownGoodDuringOutage { get; set; } = true; +} + +public sealed class MagicSettingsOptions where TSettings : class, new() +{ + public string ApplicationId { get; set; } = typeof(TSettings).Assembly.GetName().Name ?? typeof(TSettings).Name; + public string ApplicationVersion { get; set; } = typeof(TSettings).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + public int SchemaVersion { get; set; } = 1; + public TSettings Template { get; set; } = new(); + public string? Path { get; set; } + public string FileName { get; set; } = "appsettings.json"; + public string EnvironmentOverridePrefix { get; set; } = "MagicSettings__"; + public string Environment { get; set; } = string.Empty; + public bool ReloadOnChange { get; set; } = true; + public TimeSpan ReloadDebounce { get; set; } = TimeSpan.FromMilliseconds(350); + public bool PreserveUnknownProperties { get; set; } = true; + public string IdentityFileName { get; set; } = ".magicsettings.identity.json"; + public string? IdentityPath { get; set; } + public JsonSerializerOptions Json { get; } = new() { WriteIndented = true, PropertyNameCaseInsensitive = true }; + public MagicSettingsFailurePolicy Failures { get; } = new(); + public MagicControlPlaneOptions ControlPlane { get; } = new(); + public IDictionary> EnvironmentProfiles { get; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); + public IList Migrations { get; } = new List(); + public IList> Validators { get; } = new List>(); + public IList Providers { get; } = new List(); + public IDictionary ArrayPolicies { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + public ISet SensitivePaths { get; } = new HashSet(StringComparer.OrdinalIgnoreCase); + public IMagicControlPlaneTransport? ControlPlaneTransport { get; set; } + public IMagicControlPlaneEndpointResolver? ControlPlaneEndpointResolver { get; set; } + public IMagicNodeIdentityStore? IdentityStore { get; set; } + + public void ConfigureEnvironment(string environment, Action configure) + { + ArgumentException.ThrowIfNullOrWhiteSpace(environment); + ArgumentNullException.ThrowIfNull(configure); + EnvironmentProfiles[environment] = configure; + } +} + +public sealed record MagicSettingsInitializationResult(bool ShouldExit, int ExitCode, string SettingsPath, string Environment); +} +namespace MagicSettings +{ +public static class MagicSettingsEnvironmentResolver +{ + public static string Resolve(string? explicitEnvironment = null, string? hostEnvironment = null) + => FirstNonEmpty( + explicitEnvironment, + Environment.GetEnvironmentVariable("MAGICSETTINGS_ENVIRONMENT"), + Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT"), + Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"), + hostEnvironment, + "Production")!; + + private static string? FirstNonEmpty(params string?[] values) + => values.FirstOrDefault(static value => !string.IsNullOrWhiteSpace(value)); +} + +public static class MagicSettingsPathResolver +{ + public static string Resolve(MagicSettingsOptions options) where TSettings : class, new() + { + var configured = options.Path; + if (string.IsNullOrWhiteSpace(configured)) + { + configured = Environment.GetEnvironmentVariable("MAGICSETTINGS_PATH"); + } + + if (string.IsNullOrWhiteSpace(configured)) + { + return Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, options.FileName)); + } + + var fullPath = Path.GetFullPath(configured); + if (Path.HasExtension(fullPath) && string.Equals(Path.GetExtension(fullPath), ".json", StringComparison.OrdinalIgnoreCase)) + { + return fullPath; + } + + return Path.Combine(fullPath, options.FileName); + } +} + + +public static class MagicIdentityPathResolver +{ + public static string Resolve(string settingsPath, string? configuredPath, string fileName) + { + var configured = string.IsNullOrWhiteSpace(configuredPath) + ? Environment.GetEnvironmentVariable("MAGICSETTINGS_IDENTITY_PATH") + : configuredPath; + if (string.IsNullOrWhiteSpace(configured)) + { + return Path.GetFullPath(Path.Combine(Path.GetDirectoryName(settingsPath) ?? AppContext.BaseDirectory, fileName)); + } + + var fullPath = Path.GetFullPath(configured); + return Path.HasExtension(fullPath) ? fullPath : Path.Combine(fullPath, fileName); + } +} +} diff --git a/MagicSettings/MagicSettings/Source.07.cs b/MagicSettings/MagicSettings/Source.07.cs new file mode 100644 index 0000000..ba79bd8 --- /dev/null +++ b/MagicSettings/MagicSettings/Source.07.cs @@ -0,0 +1,405 @@ +// Consolidated source file. See repository history and wiki for subsystem boundaries. +using System.Text.Json.Nodes; +using MagicSettings.Share; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.DependencyInjection; +using System.Net.Http.Headers; +using System.Collections.Concurrent; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using System.Collections; +using System.Globalization; +using System.ComponentModel.DataAnnotations; + +namespace MagicSettings +{ +internal sealed class MagicSettingsConfigurationSource : IConfigurationSource +{ + private readonly MagicSettingsConfigurationProvider _provider; + public MagicSettingsConfigurationSource(MagicSettingsConfigurationProvider provider) => _provider = provider; + public IConfigurationProvider Build(IConfigurationBuilder builder) => _provider; +} + +internal sealed class MagicSettingsConfigurationProvider : ConfigurationProvider +{ + public void Publish(IReadOnlyDictionary values) + { + Data = new Dictionary(values, StringComparer.OrdinalIgnoreCase); + OnReload(); + } +} + +internal sealed class MagicSettingsRuntime : IMagicSettings where TSettings : class, new() +{ + private readonly object _gate = new(); + private readonly MagicSettingsOptions _options; + private readonly MagicSettingsConfigurationProvider _configurationProvider; + private readonly string _settingsPath; + private Dictionary _persistent = new(StringComparer.OrdinalIgnoreCase); + private Dictionary _environment = new(StringComparer.OrdinalIgnoreCase); + private Dictionary _remote = new(StringComparer.OrdinalIgnoreCase); + private MagicRemoteSnapshot _remoteSnapshot = MagicRemoteSnapshot.Empty; + private Dictionary _providers = new(StringComparer.OrdinalIgnoreCase); + private Dictionary _effective = new(StringComparer.OrdinalIgnoreCase); + private TSettings _current = new(); + private long _revision; + + public MagicSettingsRuntime(MagicSettingsOptions options, MagicSettingsConfigurationProvider configurationProvider, string settingsPath) + { + _options = options; + _configurationProvider = configurationProvider; + _settingsPath = settingsPath; + } + + public TSettings Current { get { lock (_gate) return _current; } } + public long Revision { get { lock (_gate) return _revision; } } + public event EventHandler>? Changed; + + public async ValueTask InitializeAsync(JsonObject document, CancellationToken cancellationToken) + { + var persistent = MagicJsonFlattener.Flatten(document); + var environment = MagicEnvironmentOverrides.Read(_options.EnvironmentOverridePrefix); + var providers = await LoadCustomProvidersAsync(cancellationToken); + PublishCandidate(persistent: persistent, environment: environment, providers: providers); + } + + public async ValueTask ReloadPersistentAsync(CancellationToken cancellationToken) + { + var document = await MagicSettingsDocumentStore.ReadAsync(_settingsPath, cancellationToken); + var persistent = MagicJsonFlattener.Flatten(document); + var providers = await LoadCustomProvidersAsync(cancellationToken); + PublishCandidate(persistent: persistent, providers: providers); + } + + public void RefreshEnvironment() + => PublishCandidate(environment: MagicEnvironmentOverrides.Read(_options.EnvironmentOverridePrefix)); + + public void ApplyRemoteSnapshot(MagicRemoteSnapshot snapshot, DateTimeOffset nowUtc) + { + ArgumentNullException.ThrowIfNull(snapshot); + var values = MaterializeRemote(snapshot, nowUtc); + PublishCandidate(remote: values, remoteSnapshot: snapshot, updateRemoteSnapshot: true); + } + + public void RefreshRemoteExpirations(DateTimeOffset nowUtc) + { + MagicRemoteSnapshot snapshot; + Dictionary current; + lock (_gate) + { + snapshot = _remoteSnapshot; + current = _remote; + } + + var values = MaterializeRemote(snapshot, nowUtc); + if (!DictionaryEquals(current, values)) + { + PublishCandidate(remote: values); + } + } + + public void ClearRemote() + { + PublishCandidate( + remote: new Dictionary(StringComparer.OrdinalIgnoreCase), + remoteSnapshot: MagicRemoteSnapshot.Empty, + updateRemoteSnapshot: true); + } + + public MagicSettingExplanation Explain(string path) + { + lock (_gate) + { + var sensitive = _options.SensitivePaths.Contains(path); + var sources = new[] + { + SourceValue("Remote", _remote, path, sensitive), + SourceValue("Environment", _environment, path, sensitive), + SourceValue("CustomProviders", _providers, path, sensitive), + SourceValue("PersistentFile", _persistent, path, sensitive) + }; + _effective.TryGetValue(path, out var effective); + var source = sources.FirstOrDefault(static item => item.Present)?.Source ?? "Missing"; + return new(path, sensitive && effective is not null ? "[REDACTED]" : effective, source, sources, sensitive); + } + } + + private async ValueTask> LoadCustomProvidersAsync(CancellationToken cancellationToken) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var provider in _options.Providers.OrderBy(static item => item.Priority)) + { + var values = await provider.LoadAsync(cancellationToken); + foreach (var pair in values) + { + result[pair.Key] = pair.Value; + } + } + + return result; + } + + private void PublishCandidate( + Dictionary? persistent = null, + Dictionary? providers = null, + Dictionary? environment = null, + Dictionary? remote = null, + MagicRemoteSnapshot? remoteSnapshot = null, + bool updateRemoteSnapshot = false) + { + TSettings previous; + TSettings current; + long revision; + Dictionary effective; + lock (_gate) + { + var nextPersistent = persistent ?? _persistent; + var nextProviders = providers ?? _providers; + var nextEnvironment = environment ?? _environment; + var nextRemote = remote ?? _remote; + + effective = new Dictionary(nextPersistent, StringComparer.OrdinalIgnoreCase); + Merge(effective, nextProviders); + Merge(effective, nextEnvironment); + Merge(effective, nextRemote); + + var builder = new ConfigurationBuilder().AddInMemoryCollection(effective); + current = builder.Build().Get() ?? new TSettings(); + Validate(current); + + previous = _current; + _persistent = nextPersistent; + _providers = nextProviders; + _environment = nextEnvironment; + _remote = nextRemote; + if (updateRemoteSnapshot) + { + _remoteSnapshot = remoteSnapshot ?? MagicRemoteSnapshot.Empty; + } + _current = current; + _effective = effective; + revision = ++_revision; + } + + _configurationProvider.Publish(effective); + Changed?.Invoke(this, new(previous, current, revision)); + } + + private void Validate(TSettings settings) + { + var dataAnnotationFailures = new List(); + ValidateDataAnnotations(settings, string.Empty, dataAnnotationFailures, new HashSet(ReferenceEqualityComparer.Instance)); + if (dataAnnotationFailures.Count > 0) + { + throw new InvalidOperationException($"MagicSettings validation failed: {string.Join("; ", dataAnnotationFailures)}"); + } + + foreach (var validator in _options.Validators) + { + var failures = validator.ValidateAsync(settings).AsTask().GetAwaiter().GetResult(); + if (failures.Count > 0) + { + throw new InvalidOperationException($"MagicSettings validation failed: {string.Join("; ", failures)}"); + } + } + } + + private static void ValidateDataAnnotations( + object instance, + string path, + ICollection failures, + ISet visited) + { + if (!visited.Add(instance)) + { + return; + } + + var results = new List(); + var context = new ValidationContext(instance); + if (!Validator.TryValidateObject(instance, context, results, validateAllProperties: true)) + { + foreach (var result in results) + { + var members = result.MemberNames.Any() + ? string.Join(",", result.MemberNames.Select(member => string.IsNullOrEmpty(path) ? member : $"{path}:{member}")) + : path; + failures.Add(string.IsNullOrEmpty(members) + ? result.ErrorMessage ?? "Validation failed." + : $"{members}: {result.ErrorMessage ?? "Validation failed."}"); + } + } + + foreach (var property in instance.GetType().GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)) + { + if (!property.CanRead || property.GetIndexParameters().Length != 0) + { + continue; + } + + var value = property.GetValue(instance); + if (value is null || IsSimpleValidationType(value.GetType())) + { + continue; + } + + var childPath = string.IsNullOrEmpty(path) ? property.Name : $"{path}:{property.Name}"; + if (value is IEnumerable sequence) + { + var index = 0; + foreach (var item in sequence) + { + if (item is not null && item is not string && !item.GetType().IsValueType) + { + ValidateDataAnnotations(item, $"{childPath}:{index}", failures, visited); + } + index++; + } + } + else + { + ValidateDataAnnotations(value, childPath, failures, visited); + } + } + } + + private static bool IsSimpleValidationType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type.IsPrimitive + || type.IsEnum + || type.IsValueType + || type == typeof(string) + || type == typeof(Uri) + || type == typeof(Version); + } + + private static void Merge(IDictionary target, IReadOnlyDictionary source) + { + foreach (var pair in source) + { + target[pair.Key] = pair.Value; + } + } + + private Dictionary MaterializeRemote(MagicRemoteSnapshot snapshot, DateTimeOffset nowUtc) + { + var values = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var pair in snapshot.Values) + { + if (string.Equals( + pair.Key, + _options.ControlPlane.Bootstrap.PersistentSettingPath, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var value = pair.Value; + if (value.Durability == MagicRemoteValueDurability.Expiring && value.ExpiresUtc is { } expires && expires <= nowUtc) + { + continue; + } + + values[pair.Key] = value.State == MagicValueState.Null + ? null + : value.Value is { } element ? ElementToString(element) : null; + } + return values; + } + + private static bool DictionaryEquals( + IReadOnlyDictionary left, + IReadOnlyDictionary right) + => left.Count == right.Count + && left.All(pair => right.TryGetValue(pair.Key, out var value) + && string.Equals(pair.Value, value, StringComparison.Ordinal)); + + private static MagicSettingSourceValue SourceValue(string source, IReadOnlyDictionary values, string path, bool sensitive) + { + var present = values.TryGetValue(path, out var value); + return new(source, present, sensitive && value is not null ? "[REDACTED]" : value); + } + + private static string? ElementToString(JsonElement element) + => element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Null => null, + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => element.GetRawText() + }; +} +} +namespace MagicSettings +{ +internal sealed class MagicControlPlaneSecretProvider : IMagicSecretProvider where TSettings : class, new() +{ + private readonly MagicSettingsControlPlane _controlPlane; + private readonly IMagicNodeAuthenticator _authenticator; + private readonly IMagicSecretTransport _transport; + private readonly JsonSerializerOptions _json; + + public MagicControlPlaneSecretProvider( + MagicSettingsControlPlane controlPlane, + IMagicNodeAuthenticator authenticator, + IMagicSecretTransport transport, + MagicSettingsOptions options) + { + _controlPlane = controlPlane; + _authenticator = authenticator; + _transport = transport; + _json = options.Json; + } + + public async ValueTask> GetAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if (!_controlPlane.TryGetActiveConnection(out var endpoint, out var trust)) + { + throw new InvalidOperationException("The MagicSettings control plane is not active."); + } + + var requestUri = new Uri(endpoint, "magicsettings/secrets/resolve"); + var proof = await _authenticator.CreateProofAsync( + new(trust.AuthorityId, "POST", requestUri, MagicSecretProof.ComputeBodySha256(name)), + cancellationToken); + var identity = await _authenticator.GetCurrentIdentityAsync(cancellationToken); + var response = await _transport.ResolveSecretAsync( + endpoint, + trust, + new(identity.NodeId, identity.CredentialId, name, proof), + cancellationToken); + + if (!response.Found) + { + throw new KeyNotFoundException($"MagicSettings secret '{name}' was not found."); + } + + object? value; + if (typeof(T) == typeof(string)) + { + value = response.Value; + } + else + { + value = response.Value is null ? default(T) : JsonSerializer.Deserialize(response.Value, _json); + } + + if (value is null) + { + throw new InvalidOperationException($"MagicSettings secret '{name}' could not be converted to {typeof(T).FullName}."); + } + + return new MagicSecretLease((T)value, response.ExpiresUtc); + } +} +} diff --git a/README.md b/README.md index 5aeedb4..ce85fb9 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,175 @@ # MagicSettings -Strongly typed configuration with automatic generation, versioning, and validation. + +MagicSettings is a client-owned .NET configuration system for applications that need more than copied `appsettings.{Environment}.json` files. + +It defines settings in code, keeps one persistent JSON document current, applies explicit migrations, composes transient overrides without writing secrets back to disk, supports live reloads, and optionally lets each application instance initiate a secure relationship with a control plane. + +## What it provides + +- Strongly typed code-defined templates. +- Automatic first-run `appsettings.json` generation. +- Non-destructive reconciliation that adds newly introduced properties. +- Explicit sequential migrations for renames, removals, and transformations. +- Conservative array handling with per-path merge policies. +- Runtime precedence: persistent file → custom providers → OS environment → remote in-memory overrides. +- Standard `IConfiguration`, `IOptions`, and `IOptionsMonitor` compatibility. +- A stable installation identity created during first initialization. +- Client-initiated control-plane synchronization with startup or later activation. +- Request-bound cryptographic proofs that can authenticate the same node to other APIs. +- Credential rotation, revocation helpers, and destructive identity reset without exposing private key material. +- Explicit asynchronous on-demand secret retrieval using the same node proof model. +- Storage-agnostic server helpers for enrollment, authorization caches, replay defense, and migration review. + +## Quick start + +```csharp +var builder = WebApplication.CreateBuilder(args); + +var magic = await builder.AddMagicSettingsAsync( + args, + options => + { + options.ApplicationId = "MagicAiGateway"; + options.SchemaVersion = 3; + options.Template = AppSettings.CreateDefaults(); + + options.ArrayPolicies["AllowedOrigins"] = + MagicArrayMergePolicy.Union; + + options.ControlPlane.Bootstrap.EnvironmentVariableName = + "MAGICSETTINGS_CONTROL_PLANE_ENDPOINT"; + options.ControlPlane.Bootstrap.PersistentSettingPath = + "MagicSettings:ControlPlane:Endpoint"; + options.ControlPlane.Bootstrap.Trust = + MagicControlPlaneTrust.SystemTls("MagicSettings.ControlPlane"); + options.ControlPlane.Bootstrap.ConnectOnStartup = true; + }); + +if (magic.ShouldExit) +{ + return; +} +``` + +Ordinary .NET configuration consumers continue to work: + +```csharp +var host = builder.Configuration["Database:Host"]; +builder.Services.Configure( + builder.Configuration.GetSection("Database")); +``` + +Advanced operations use explicit MagicSettings services: + +```csharp +var controlPlane = app.Services + .GetRequiredService(); + +await controlPlane.ConfigureAsync( + discoveredEndpoint, + MagicControlPlaneTrust.Pinned( + "MagicSettings.ControlPlane", + expectedServerFingerprint)); +``` + +## Configuration precedence + +```text +remote in-memory snapshot highest +MagicSettings__ OS environment +custom source providers +persistent appsettings.json lowest runtime source +``` + +The code template is not a transient override. It generates and repairs the persistent document. Environment, custom-provider, and remote values are never serialized into that file. + +## Control-plane endpoint bootstrap + +A node may know its control plane during startup, discover it later, or never use one. Endpoint resolution is deliberately local-only: + +```text +explicit ConfigureAsync(...) +dedicated OS environment variable +persistent local appsettings value +code fallback +no endpoint / remote disabled +``` + +A remote setting cannot redirect the node to a new authority. Authority transitions require application code or another trusted local bootstrap source. + +## Node authentication + +MagicSettings does **not** expose a normal `GetPrivateKey()` API. Application code asks `IMagicNodeAuthenticator` to create a fresh proof bound to: + +- the intended API audience, +- HTTP method, +- exact target URI, +- request-body SHA-256, +- a short validity window, +- and a one-time nonce. + +```csharp +var authenticator = services + .GetRequiredService(); + +var identity = await authenticator.GetCurrentIdentityAsync(); +var proof = await authenticator.CreateProofAsync( + new MagicAuthenticationRequest( + "Inventory.Api", + "POST", + requestUri, + MagicHash.Sha256Hex(body))); +``` + +For normal `HttpClient` usage: + +```csharp +services.AddHttpClient() + .AddMagicNodeAuthentication("Inventory.Api"); +``` + +The identity descriptor contains only public information: node ID, credential ID, algorithm, public key, and fingerprint. + +On-demand secrets remain explicit and asynchronous: + +```csharp +var secrets = app.Services.GetRequiredService(); +await using var password = await secrets.GetAsync("Database:Password"); +``` + +The default HTTP transport signs the secret name and request target. It never writes the returned value into `appsettings.json`. + +## Identity lifecycle + +- **Rotation** keeps the logical node ID, creates a new credential, and produces a continuity proof signed by the old credential. +- **Reset** creates a completely new node identity and requires server approval again. +- Losing the identity file has the same trust consequence as reset: the replacement identity is not the old authorized node. +- Local regeneration does not revoke a stolen credential. The authorization server must revoke the old credential and distribute that change to relying APIs. + +## Documentation + +- [API reference](wiki/api-reference.md) +- [AI agent usage guide](wiki/agent-usage.md) +- [Getting started](wiki/getting-started.md) +- [Architecture and ownership](wiki/configuration.md) +- [Configuration precedence](wiki/configuration.md) +- [Paths and environments](wiki/configuration.md) +- [Reconciliation and arrays](wiki/configuration.md) +- [Migrations](wiki/migrations.md) +- [Providers](wiki/configuration.md) +- [Control-plane integration](wiki/control-plane.md) +- [Endpoint bootstrap](wiki/control-plane.md) +- [Identity and enrollment](wiki/control-plane.md) +- [API authentication proofs](wiki/control-plane.md) +- [Rotation, reset, and revocation](wiki/control-plane.md) +- [Outages and stale state](wiki/control-plane.md) +- [Secrets](wiki/secrets-and-security.md) +- [Diagnostics](wiki/configuration.md) +- [Security boundaries](wiki/secrets-and-security.md) +- [Testing](wiki/testing.md) + +## Security boundary + +MagicSettings can authenticate a node, sign request-specific proofs, preserve provider provenance, keep remote values out of the persistent file, and help a server reject replayed requests. It cannot protect secrets from a process or administrator that already controls the machine, securely erase arbitrary managed strings, or revoke a stolen credential without server participation. + +Licensed under Apache-2.0. diff --git a/wiki/README.md b/wiki/README.md new file mode 100644 index 0000000..613535a --- /dev/null +++ b/wiki/README.md @@ -0,0 +1,24 @@ +# MagicSettings documentation + +MagicSettings is intentionally split into three packages: + +- `MagicSettings` contains the application runtime, persistent document handling, endpoint bootstrap, identity storage, proof creation, and hosted synchronization. +- `MagicSettings.Share` contains contracts understood by both applications and servers. +- `MagicSettings.Server` contains storage-agnostic helpers. It is not a complete control-plane product. + +## Guides + +1. [API reference](api-reference.md) +2. [AI agent usage guide](agent-usage.md) +3. [Getting started](getting-started.md) +4. [Architecture](configuration.md) +5. [Configuration precedence](configuration.md) +6. [Paths and environments](configuration.md) +7. [Reconciliation and arrays](configuration.md) +8. [Migrations](migrations.md) +9. [Providers](configuration.md) +10. [Control-plane integration](control-plane.md) +11. [Secrets](secrets-and-security.md) +12. [Diagnostics](configuration.md) +13. [Security boundaries](secrets-and-security.md) +14. [Testing](testing.md) diff --git a/wiki/agent-usage.md b/wiki/agent-usage.md new file mode 100644 index 0000000..8ac89a0 --- /dev/null +++ b/wiki/agent-usage.md @@ -0,0 +1,516 @@ +# AI agent usage guide + +This guide is written for coding agents and developers integrating MagicSettings into another repository. Use it as a task-to-API map. For exact symbol descriptions, see [API reference](api-reference.md). + +## Read this first + +MagicSettings has three different concepts that agents must not collapse into one: + +1. **Persistent configuration**: the local JSON document maintained from the code template. +2. **Transient overrides**: custom providers, OS environment variables, and remote snapshots layered above the file. +3. **Node identity**: a separate cryptographic credential used for enrollment and request authentication. + +Regenerating settings must not reset identity. Resetting identity must not rewrite settings. Remote values must not be persisted into the local JSON file. + +## Task index + +| Goal | Primary API | +|---|---| +| Add MagicSettings to an application | `AddMagicSettingsAsync` | +| Read typed effective settings | `IMagicSettings` or `IOptions` | +| Determine where a value came from | `IMagicSettings.Explain` | +| Add an environment-specific default | `MagicSettingsOptions.ConfigureEnvironment` | +| Add a new settings property | Update `TSettings.Template`; reconciliation adds it locally | +| Rename or transform a setting | `IMagicSettingsMigration` + `MagicMigrationContext` | +| Define array behavior | `ArrayPolicies[path] = MagicArrayMergePolicy...` | +| Add a custom bulk source | `IMagicSettingsSourceProvider` | +| Discover/connect to a control plane later | `IMagicSettingsControlPlane.ConfigureAsync` | +| Force immediate remote refresh | `IMagicSettingsControlPlane.RefreshAsync` | +| Authenticate to another API | `AddMagicNodeAuthentication` or `IMagicNodeAuthenticator.CreateProofAsync` | +| Inspect public node identity | `IMagicNodeAuthenticator.GetCurrentIdentityAsync` | +| Rotate a healthy credential | `IMagicNodeIdentityManager.RotateAsync` | +| Replace a lost/compromised identity | `IMagicNodeIdentityManager.ResetAsync` | +| Resolve a secret on demand | `IMagicSecretProvider.GetAsync` | +| Verify a signed API request | `MagicNodeProofVerifier` or `VerifyHttpRequestAsync` | +| Approve/revoke a credential | `MagicCredentialAdministrationService` | +| Implement server persistence | `IMagicCredentialRegistry`, `IMagicReplayCache`, `IMagicNodeRemoteRecordStore` | + +--- + +# Application integration workflows + +## 1. Add MagicSettings to a new application + +```csharp +var builder = WebApplication.CreateBuilder(args); + +var initialization = await builder.AddMagicSettingsAsync( + args, + options => + { + options.ApplicationId = "Orders.Api"; + options.ApplicationVersion = "1.0.0"; + options.SchemaVersion = 1; + options.Template = AppSettings.CreateDefaults(); + + options.SensitivePaths.Add("Database:Password"); + options.ArrayPolicies["Cors:AllowedOrigins"] = + MagicArrayMergePolicy.Union; + }); + +if (initialization.ShouldExit) +{ + return; +} + +var app = builder.Build(); +``` + +Agent rules: + +- Call it exactly once per host. +- Call it before `builder.Build()`. +- Do not separately register another MagicSettings runtime for the same `TSettings`. +- Do not write environment or remote values into `options.Template` at runtime. +- Treat `Template` as the persistent-file schema/default source. + +## 2. Consume settings + +Standard .NET consumption: + +```csharp +builder.Services.Configure( + builder.Configuration.GetSection("Database")); +``` + +MagicSettings-specific consumption: + +```csharp +public sealed class Worker(IMagicSettings settings) +{ + public void Run() + { + var current = settings.Current; + var revision = settings.Revision; + } +} +``` + +Listen for validated changes: + +```csharp +settings.Changed += (_, change) => +{ + logger.LogInformation( + "Settings revision {Revision} activated", + change.Revision); +}; +``` + +Do not mutate `settings.Current` and expect persistence or publication. It is a snapshot, not an editing API. + +## 3. Explain precedence + +```csharp +var explanation = settings.Explain("Database:Host"); +``` + +Use this for diagnostics, support tooling, and tests. Sensitive paths are redacted. + +Effective precedence, lowest to highest: + +```text +persistent JSON +custom providers +MagicSettings__ environment variables +remote snapshot +``` + +## 4. Configure environment-specific defaults + +```csharp +options.ConfigureEnvironment("Development", settings => +{ + settings.Database.Host = "localhost"; + settings.Logging.MinimumLevel = "Debug"; +}); +``` + +This changes the code template before reconciliation. It does not create a hidden runtime provider. + +## 5. Add a setting without breaking existing installations + +1. Add the property to the typed settings model. +2. Add its default to the template. +3. Leave `SchemaVersion` unchanged when adding a straightforward missing property. +4. Reconciliation will add it to existing files while preserving existing values. + +Increase `SchemaVersion` only when a semantic migration is required. + +## 6. Migrate a renamed setting + +```csharp +public sealed class DatabaseHostMigration : IMagicSettingsMigration +{ + public int FromVersion => 1; + public int ToVersion => 2; + + public void Apply(JsonObject document, MagicMigrationContext context) + { + context.Rename( + document, + "Database:Server", + "Database:Host"); + } +} +``` + +Register it: + +```csharp +options.SchemaVersion = 2; +options.Migrations.Add(new DatabaseHostMigration()); +``` + +Agent rules: + +- Every schema version transition must be reachable through sequential migrations. +- Never silently skip a version. +- Use `Remove` for local deletion; it deliberately creates a destructive server review item. +- Mark a transformation remotely safe only when the same transformation is unquestionably valid for stored server values. + +## 7. Configure arrays + +```csharp +options.ArrayPolicies["Cors:AllowedOrigins"] = + MagicArrayMergePolicy.Union; +``` + +Choose policy from the actual semantics: + +- Ordered middleware/pipeline: usually `PreserveExisting` or `ReplaceWithTemplate`. +- Set-like origins or capabilities: often `Union`. +- Operator-curated lists: usually `PreserveExisting`. + +Never select `Union` merely to suppress a strict-development error. + +## 8. Add a custom provider + +```csharp +public sealed class LocalDatabaseSettingsProvider : IMagicSettingsSourceProvider +{ + public string Name => "LocalDatabase"; + public int Priority => 100; + + public async ValueTask> LoadAsync( + CancellationToken cancellationToken = default) + { + return await LoadCompleteSnapshotAsync(cancellationToken); + } +} +``` + +```csharp +options.Providers.Add(new LocalDatabaseSettingsProvider()); +``` + +Return a complete snapshot for that provider, not only changes since the previous call. + +--- + +# Control-plane workflows + +## 9. Configure startup endpoint bootstrap + +```csharp +options.ControlPlane.Bootstrap.EnvironmentVariableName = + "MAGICSETTINGS_CONTROL_PLANE_ENDPOINT"; + +options.ControlPlane.Bootstrap.PersistentSettingPath = + "MagicSettings:ControlPlane:Endpoint"; + +options.ControlPlane.Bootstrap.CodeFallbackEndpoint = + new Uri("https://control.internal/"); + +options.ControlPlane.Bootstrap.Trust = + MagicControlPlaneTrust.SystemTls("MagicSettings.ControlPlane"); + +options.ControlPlane.Bootstrap.ConnectOnStartup = true; +``` + +Bootstrap resolution is local-only. Do not change code to resolve the endpoint from `builder.Configuration` after MagicSettings providers are composed, because that effective configuration may contain remote values. + +## 10. Connect after service discovery + +```csharp +var controlPlane = services + .GetRequiredService(); + +await controlPlane.ConfigureAsync( + discoveredEndpoint, + MagicControlPlaneTrust.Pinned( + "MagicSettings.ControlPlane", + expectedPublicKeyFingerprint), + cancellationToken); +``` + +Use this when discovery occurs after startup or another local service tells the application where its control plane lives. + +The new endpoint does not become authoritative until synchronization succeeds. + +## 11. Disconnect + +Keep the last known good remote snapshot: + +```csharp +await controlPlane.DisconnectAsync( + clearRemoteOverrides: false, + cancellationToken); +``` + +Reveal lower-priority providers immediately: + +```csharp +await controlPlane.DisconnectAsync( + clearRemoteOverrides: true, + cancellationToken); +``` + +Choose intentionally; clearing remote values may alter live application behavior. + +## 12. Implement a custom transport + +Implement `IMagicControlPlaneTransport`. Implement `IMagicSecretTransport` as well when the same transport should support `IMagicSecretProvider` registration. + +The custom transport must: + +- Send the exact signed synchronization request. +- Validate the supplied trust policy. +- Avoid redirects that change the signed target or authority. +- Preserve cancellation and timeout behavior. +- Return a complete snapshot, not a partial patch. + +--- + +# Node authentication workflows + +## 13. Authenticate a normal `HttpClient` + +```csharp +builder.Services + .AddHttpClient() + .AddMagicNodeAuthentication("Inventory.Api"); +``` + +This is the preferred path. The handler signs each actual request and body. + +The audience is not a display label. It is a security boundary and must match the receiving API exactly. + +## 14. Create a proof for a custom protocol + +```csharp +var authenticator = services + .GetRequiredService(); + +var proof = await authenticator.CreateProofAsync( + new MagicAuthenticationRequest( + Audience: "Inventory.Api", + Method: "POST", + Uri: requestUri, + BodySha256: MagicHash.Sha256Hex(payload), + ValidFor: TimeSpan.FromSeconds(60)), + cancellationToken); +``` + +Transmit the proof and public node/credential IDs according to the custom protocol. Never transmit the private key. + +Do not pre-create a proof and reuse it across calls. Each request needs a fresh nonce and signature. + +## 15. Inspect public identity + +```csharp +var identity = await authenticator.GetCurrentIdentityAsync(cancellationToken); +``` + +Safe fields include node ID, credential ID, algorithm, public key, fingerprint, and creation time. + +The public key is not the secret. The private key remains inside the identity store/signing service. + +## 16. Rotate a healthy credential + +```csharp +var identityManager = services + .GetRequiredService(); + +var change = await identityManager.RotateAsync( + "Scheduled annual rotation", + cancellationToken); +``` + +Rotation: + +- Keeps the node ID. +- Generates a new credential ID and keypair. +- Produces a continuity proof signed by the old credential. +- Sends that proof on the next synchronization. +- May be auto-approved or manually approved according to server policy. + +Use only while the old key is still trusted. + +## 17. Reset a lost or compromised identity + +```csharp +var change = await identityManager.ResetAsync( + new MagicIdentityResetRequest( + "Credential may have been copied", + ConfirmDestructiveReset: true), + cancellationToken); +``` + +Reset: + +- Generates a new node ID and credential. +- Has no continuity proof. +- Clears the current remote trust relationship. +- Requires approval as a new node. + +After suspected compromise, also revoke the old server credential. Local reset alone does not neutralize a stolen key. + +## 18. Provide a secure identity store + +Implement `IMagicNodeIdentityStore` when plain file storage is not sufficient. + +Requirements: + +- Atomic replacement or transactional save. +- Access restricted to the application identity. +- No logging or telemetry of private material. +- Stable retrieval across restarts. +- Explicit deletion behavior. +- Prefer non-exportable hardware/OS-backed keys where possible. + +`MagicStoredNodeIdentity` contains private material. Treat any code touching it as security-critical. + +--- + +# Secret workflows + +## 19. Resolve a secret + +```csharp +var secretProvider = services + .GetRequiredService(); + +await using var password = await secretProvider.GetAsync( + "Database:Password", + cancellationToken); + +UsePassword(password.Value); +``` + +The built-in provider requires an active control-plane connection. It signs the secret name and request target and does not write the result into the settings file. + +Do not copy secret values into ordinary logs, exceptions, settings objects, or long-lived singleton state. + +--- + +# Server implementation workflows + +## 20. Verify an ASP.NET API request + +```csharp +var result = await verifier.VerifyHttpRequestAsync( + httpContext.Request, + expectedAudience: "Inventory.Api", + cancellationToken); + +if (!result.IsValid) +{ + return Results.Unauthorized(); +} +``` + +The API needs: + +- `IMagicCredentialRegistry` populated with approved public credentials. +- `IMagicReplayCache` shared consistently across replicas. +- A stable expected audience. + +## 21. Approve or revoke a node credential + +```csharp +await administration.ApproveAsync(nodeId, credentialId, cancellationToken); +await administration.RevokeAsync(nodeId, credentialId, cancellationToken); +``` + +Approval and revocation should generate audit records in the hosting application. + +APIs caching credential status must have a defined refresh/freshness policy. Revocation is only as fast as its distribution. + +## 22. Implement production stores + +Implement: + +- `IMagicCredentialRegistry` +- `IMagicReplayCache` +- `IMagicNodeRemoteRecordStore` +- `IMagicSecretResolver` when secrets are enabled + +The included in-memory classes are for tests, demos, and single-process experiments. They are not durable and do not coordinate replicas. + +## 23. Process synchronization + +Use `MagicSettingsSyncService.SynchronizeAsync` from the control-plane endpoint. + +It handles: + +- Signed payload validation. +- First-time proof-of-possession enrollment. +- Pending approval state. +- Approved credential validation. +- Rotation continuity proofs. +- Schema manifest retention. +- Migration review-item retention. +- Node-specific remote snapshot response. + +Do not replace the service with logic that trusts node ID or hostname without a valid proof. + +## 24. Resolve server-side secrets + +Implement `IMagicSecretResolver`, then call `MagicSecretService.ResolveAsync` from the API endpoint. + +The resolver receives the already verified node ID and requested name. The hosting application remains responsible for per-node authorization, audit, secret storage, and response policy. + +--- + +# Agent anti-patterns + +An AI agent modifying this library or integrating it elsewhere must not: + +- Add `GetPrivateKey`, `ExportPrivateKey`, or a general certificate-with-private-key getter. +- Use one static signature as an API key. +- Disable replay protection because proofs are short-lived. +- Resolve the control-plane endpoint from the remote-effective configuration layer. +- Write remote or environment overrides back into `appsettings.json`. +- Reset identity as part of forced settings regeneration. +- Auto-delete server-side values because a client migration removed a path. +- Auto-approve a reset identity based only on matching application name, hostname, or node label. +- Use in-memory credential or replay stores in a replicated production API. +- Log complete proof payloads, secret values, or private identity records. +- Swallow migration-chain failures and continue with an unknown schema. +- Treat arrays as sets without explicit semantic justification. + +# Recommended context order for agents + +When an agent is asked to change MagicSettings behavior, read files in this order: + +1. `README.md` +2. `wiki/agent-usage.md` +3. `wiki/api-reference.md` +4. The topic-specific wiki page. +5. Public contracts in `MagicSettings.Share`. +6. Public interfaces/options in `MagicSettings`. +7. Server helpers when the change affects enrollment, verification, or storage. +8. Tests covering the relevant behavior. + +Then add or update tests before changing security-sensitive behavior. diff --git a/wiki/api-reference.md b/wiki/api-reference.md new file mode 100644 index 0000000..9e757d9 --- /dev/null +++ b/wiki/api-reference.md @@ -0,0 +1,679 @@ +# API reference + +This reference describes the public MagicSettings API that application developers, control-plane implementers, and AI coding agents are expected to use. It intentionally focuses on behavior, ownership, persistence, security boundaries, and common call patterns—not merely symbol names. + +## Package map + +| Package | Purpose | +|---|---| +| `MagicSettings` | Application runtime, initialization, configuration composition, migrations, endpoint bootstrap, identity storage, request signing, HTTP helpers, and secret clients. | +| `MagicSettings.Share` | Protocol records and enums shared by clients, APIs, and control-plane implementations. | +| `MagicSettings.Server` | Storage-agnostic proof verification, enrollment, credential lifecycle, synchronization, replay defense, and secret-resolution helpers. | + +## Normal application entry points + +Most applications only need these APIs: + +- `AddMagicSettingsAsync` during host construction. +- `IMagicSettings` to inspect the typed effective snapshot and provenance. +- `IOptions` or `IOptionsMonitor` for standard .NET consumption. +- `IMagicSettingsControlPlane` when the endpoint is discovered or changed at runtime. +- `IMagicNodeAuthenticator` to authenticate the node to another API. +- `AddMagicNodeAuthentication` for normal `HttpClient` integration. +- `IMagicNodeIdentityManager` for intentional credential rotation or destructive reset. +- `IMagicSecretProvider` for explicit asynchronous secret retrieval. + +Protocol DTOs and server helpers are normally used by infrastructure libraries, API middleware, or a control-plane implementation rather than ordinary business code. + +--- + +# MagicSettings package + +## Initialization + +### `MagicSettingsInitializationExtensions.AddMagicSettingsAsync` + +```csharp +ValueTask AddMagicSettingsAsync( + this IHostApplicationBuilder builder, + string[]? args = null, + Action>? configure = null, + CancellationToken cancellationToken = default) + where TSettings : class, new(); +``` + +Primary initialization method. Call it once, immediately after creating the host builder. + +It: + +1. Resolves the environment and settings path. +2. Applies the selected code-defined environment profile to the template. +3. Generates or reconciles the persistent JSON file. +4. Runs required sequential migrations. +5. Creates or loads the node identity. +6. Builds the initial effective settings snapshot. +7. Adds MagicSettings to `IConfiguration`. +8. Registers the typed runtime, options support, identity services, control-plane services, hosted reload service, and—when supported by the selected transport—the secret provider. + +The method may throw when the persistent document is malformed, a migration chain is incomplete, validation fails, a strict array policy is missing, or security-sensitive bootstrap configuration is invalid. + +### `MagicSettingsInitializationResult` + +| Property | Meaning | +|---|---| +| `ShouldExit` | `true` when a MagicSettings command-line operation completed and the host should not start. | +| `ExitCode` | Exit code for the command-line operation. | +| `SettingsPath` | Fully resolved persistent settings file path. | +| `Environment` | Effective MagicSettings environment. | + +### `MagicSettingsRuntimeRegistration` + +Contains the resolved `SettingsPath` and is registered in dependency injection for infrastructure code that needs the runtime file location. + +## Runtime settings access + +### `IMagicSettings` + +```csharp +TSettings Current { get; } +long Revision { get; } +event EventHandler>? Changed; +MagicSettingExplanation Explain(string path); +``` + +- `Current` returns the latest validated typed snapshot. Treat the returned object as read-only application state. +- `Revision` increments whenever a candidate snapshot is successfully published. +- `Changed` fires after a new validated snapshot becomes active. +- `Explain(path)` reports which provider supplied a path and which value won. + +Failed reloads do not replace `Current`; the last known good snapshot remains active. + +### `MagicSettingsChangedEventArgs` + +Contains `Previous`, `Current`, and the newly published `Revision`. + +### `MagicSettingExplanation` + +| Property | Meaning | +|---|---| +| `Path` | Requested configuration path. | +| `EffectiveValue` | Winning value, redacted when sensitive. | +| `EffectiveSource` | Winning provider name or `Missing`. | +| `Sources` | Per-provider presence and value information. | +| `IsSensitive` | Whether diagnostics must redact the value. | + +### `MagicSettingSourceValue` + +Describes one provider contribution through `Source`, `Present`, and `Value`. + +## Main options + +### `MagicSettingsOptions` + +| Property | Purpose | +|---|---| +| `ApplicationId` | Stable logical application identifier sent in schema manifests and remote-record keys. | +| `ApplicationVersion` | Application version reported to the control plane. | +| `SchemaVersion` | Current local settings schema version. Increase it only with a complete migration chain. | +| `Template` | Code-defined settings defaults used to generate and reconcile the persistent file. | +| `Path` | Explicit JSON file or directory path. Overrides `MAGICSETTINGS_PATH`. | +| `FileName` | File name appended when the selected path is a directory. Defaults to `appsettings.json`. | +| `EnvironmentOverridePrefix` | Prefix for OS/process environment overrides. Defaults to `MagicSettings__`. | +| `Environment` | Explicit environment name; when empty, standard environment sources are resolved. | +| `ReloadOnChange` | Enables persistent-file change detection. | +| `ReloadDebounce` | Delay used to avoid reading a partially written settings file. | +| `PreserveUnknownProperties` | Declares the intended non-destructive behavior for properties no longer known by the current template. | +| `IdentityFileName` | Default identity filename when the identity path is a directory. | +| `IdentityPath` | Explicit identity file or directory path. Overrides `MAGICSETTINGS_IDENTITY_PATH`. | +| `Json` | Serializer options used for settings documents and related serialization. | +| `Failures` | Failure-policy configuration. | +| `ControlPlane` | Bootstrap, polling, and outage behavior. | +| `EnvironmentProfiles` | Environment-specific mutations applied to the template before reconciliation. Prefer `ConfigureEnvironment`. | +| `Migrations` | Ordered migration implementations. | +| `Validators` | Additional typed validators. | +| `Providers` | Custom bulk configuration providers. | +| `ArrayPolicies` | Per-path array reconciliation policy. | +| `SensitivePaths` | Paths redacted from provenance diagnostics. | +| `ControlPlaneTransport` | Optional custom synchronization transport. | +| `ControlPlaneEndpointResolver` | Optional custom local bootstrap resolver. | +| `IdentityStore` | Optional OS-, vault-, or hardware-backed identity store. | + +### `ConfigureEnvironment` + +```csharp +void ConfigureEnvironment(string environment, Action configure); +``` + +Registers a template mutation for one environment. The mutation runs before the persistent file is generated or reconciled. It does not become a hidden runtime override. + +### `MagicSettingsFailurePolicy` + +| Property | Purpose | +|---|---| +| `StrictDevelopmentMode` | Makes ambiguous development/local/test behavior fail early. | +| `AmbiguousArrayInProduction` | Intended production response to ambiguous array reconciliation. | +| `RuntimeReloadFailure` | Intended response when a changed file cannot produce a valid snapshot. | + +### `MagicFailureAction` + +- `StopStartup` +- `WarnAndContinue` +- `KeepLastKnownGood` + +### `MagicArrayMergePolicy` + +- `PreserveExisting`: keep the operator-managed array unchanged. +- `ReplaceWithTemplate`: replace the persistent array with the code template. +- `AppendMissing`: append template items not already present. +- `Union`: retain unique existing items and add unique template items. + +Do not assume arrays are sets unless their semantics genuinely are set-like. + +## Validation, providers, and migrations + +### `IMagicSettingsValidator` + +```csharp +ValueTask> ValidateAsync( + TSettings settings, + CancellationToken cancellationToken = default); +``` + +Returns validation failures. An empty list means success. Validation occurs before a candidate snapshot is published. + +### `IMagicSettingsSourceProvider` + +```csharp +string Name { get; } +int Priority { get; } +ValueTask> LoadAsync( + CancellationToken cancellationToken = default); +``` + +Implements a complete bulk provider snapshot. Providers are applied after the persistent file and before OS environment and remote values. Higher-priority providers are applied later among custom providers. + +Use this for mounted files, a local database, a vault cache, or another source that can return a complete path/value snapshot. Use `IMagicSecretProvider` instead for on-demand secrets. + +### `IMagicSettingsMigration` + +```csharp +int FromVersion { get; } +int ToVersion { get; } +void Apply(JsonObject document, MagicMigrationContext context); +``` + +Each migration must advance the version. Missing steps, downgrade attempts, and non-advancing migrations stop initialization. + +### `MagicMigrationContext` + +| Member | Purpose | +|---|---| +| `SafeOperations` | Operations safe to report as automatically applied. | +| `ReviewItems` | Remote or destructive effects requiring administrative review. | +| `RecordSafe(operation)` | Adds an informational safe-operation record. | +| `RequireReview(...)` | Adds an explicit migration review item. | +| `Rename(document, from, to, remoteSafeProjection)` | Renames a local path and optionally records a remote review requirement. | +| `Transform(document, path, transform, description, remoteSafeProjection)` | Transforms a local value and records review unless explicitly marked remotely safe. | +| `SetIfMissing(document, path, value)` | Adds a value only when absent. | +| `Remove(document, path, reason)` | Removes locally and always creates a destructive remote review item. | + +## Attributes + +### `MagicSensitiveAttribute` + +Marks a settings property as sensitive in the generated schema manifest. Sensitive values are redacted from `Explain` output. + +### `MagicRemoteOverrideAttribute` + +```csharp +[MagicRemoteOverride(false)] +``` + +Controls whether the control plane may supply a value for a property. The control-plane endpoint path is additionally blocked regardless of ordinary remote settings. + +### `MagicSettingDescriptionAttribute` + +Adds human-readable schema metadata for a property. + +## Path and environment helpers + +### `MagicSettingsEnvironmentResolver.Resolve` + +Resolves the environment in this order: explicit value, `MAGICSETTINGS_ENVIRONMENT`, `DOTNET_ENVIRONMENT`, `ASPNETCORE_ENVIRONMENT`, host environment, then `Production`. + +### `MagicSettingsPathResolver.Resolve` + +Resolves `options.Path`, then `MAGICSETTINGS_PATH`, then `AppContext.BaseDirectory/options.FileName`. A `.json` path is treated as a file; another path is treated as a directory. + +### `MagicIdentityPathResolver.Resolve` + +Resolves the explicit identity path, then `MAGICSETTINGS_IDENTITY_PATH`, then a file adjacent to the settings document. + +## Control-plane bootstrap and lifecycle + +### `MagicControlPlaneBootstrapOptions` + +| Property | Purpose | +|---|---| +| `EnvironmentVariableName` | Dedicated bootstrap endpoint variable. Default: `MAGICSETTINGS_CONTROL_PLANE_ENDPOINT`. | +| `PersistentSettingPath` | Path read from the persistent local JSON document. Default: `MagicSettings:ControlPlane:Endpoint`. | +| `CodeFallbackEndpoint` | Final code-defined fallback endpoint. | +| `Trust` | Trust policy used for startup and watched local endpoint changes. | +| `ConnectOnStartup` | Attempts synchronization during hosted-service startup. | +| `WatchPersistentEndpoint` | Re-evaluates the persistent endpoint after a valid file reload. | +| `AllowInsecureHttp` | Allows non-loopback HTTP. This should almost never be enabled. | + +Resolution precedence is runtime override, dedicated environment variable, persistent local document, code fallback, then disabled. Remote effective settings never participate. + +### `MagicControlPlaneOptions` + +| Property | Purpose | +|---|---| +| `Bootstrap` | Endpoint discovery and trust configuration. | +| `PollInterval` | Base synchronization interval. | +| `PollJitter` | Random variation to avoid synchronized fleet polling. | +| `KeepLastKnownGoodDuringOutage` | Retains the last valid remote layer when synchronization fails. | + +### `IMagicSettingsControlPlane` + +```csharp +MagicControlPlaneState State { get; } +MagicResolvedControlPlaneEndpoint CurrentEndpoint { get; } +ValueTask ConfigureAsync(Uri endpoint, MagicControlPlaneTrust trust, CancellationToken cancellationToken = default); +ValueTask DisconnectAsync(bool clearRemoteOverrides = false, CancellationToken cancellationToken = default); +ValueTask RefreshAsync(CancellationToken cancellationToken = default); +``` + +- `ConfigureAsync` authenticates and synchronizes with a runtime-selected endpoint. A failed transition does not accept values from the new endpoint. +- `DisconnectAsync(false)` stops using the connection while preserving the last remote snapshot. +- `DisconnectAsync(true)` also clears the remote layer and reveals lower-priority values. +- `RefreshAsync` performs an immediate client-initiated synchronization when configured. + +### `IMagicControlPlaneEndpointResolver` + +Customizes local endpoint resolution. Implementations receive the persistent document—not the final effective snapshot—to prevent remote self-redirection. + +### `MagicControlPlaneEndpointResolver` + +Default resolver implementing the documented local-only precedence and HTTPS validation. + +### `IMagicControlPlaneTransport` + +```csharp +ValueTask SynchronizeAsync( + Uri endpoint, + MagicControlPlaneTrust trust, + MagicSettingsSyncRequest request, + CancellationToken cancellationToken = default); +``` + +Extension point for non-HTTP transports or custom HTTP behavior. The transport must preserve request integrity and enforce the supplied trust policy. + +### `HttpMagicControlPlaneTransport` + +Default HTTP transport. It posts to `magicsettings/sync`, uses the `MagicNode` authorization scheme, supports system TLS or public-key fingerprint pinning, disables redirects, and can also resolve secrets. + +## Node identity and authentication + +### `IMagicNodeAuthenticator` + +```csharp +ValueTask GetCurrentIdentityAsync(CancellationToken cancellationToken = default); +ValueTask CreateProofAsync(MagicAuthenticationRequest request, CancellationToken cancellationToken = default); +``` + +`GetCurrentIdentityAsync` returns public identity metadata only. `CreateProofAsync` signs one short-lived, audience-bound request. It does not expose the private key. + +### `IMagicNodeIdentityManager` + +```csharp +event EventHandler? IdentityChanged; +ValueTask GetCurrentAsync(CancellationToken cancellationToken = default); +ValueTask RotateAsync(string reason, CancellationToken cancellationToken = default); +ValueTask ResetAsync(MagicIdentityResetRequest request, CancellationToken cancellationToken = default); +``` + +- `RotateAsync` retains the logical node ID, generates a new credential, and produces a continuity proof signed by the old credential. +- `ResetAsync` creates a new node ID and credential. It requires `ConfirmDestructiveReset=true`, clears remote trust continuity, and requires enrollment approval again. +- `IdentityChanged` allows the synchronization layer and application telemetry to react to lifecycle changes. + +Rotation is not a substitute for revocation after compromise. A stolen old key remains usable until the authorization system revokes it and relying APIs refresh their caches. + +### `MagicNodeIdentityManager` + +Default implementation of both `IMagicNodeIdentityManager` and `IMagicNodeAuthenticator` using ECDSA P-256 credentials. + +Proof lifetimes must be greater than zero and no longer than five minutes; the default is one minute. + +### `IMagicNodeIdentityStore` + +```csharp +ValueTask LoadAsync(CancellationToken cancellationToken = default); +ValueTask SaveAsync(MagicStoredNodeIdentity identity, CancellationToken cancellationToken = default); +ValueTask DeleteAsync(CancellationToken cancellationToken = default); +``` + +Security-sensitive extension point. A custom implementation may use DPAPI, Keychain, TPM, HSM, Kubernetes, or another non-exportable key facility. + +### `FileMagicNodeIdentityStore` + +Portable file implementation. It writes atomically and restricts Unix permissions to the current user, but it is not equivalent to a hardware-backed vault. + +### `MagicStoredNodeIdentity` + +Contains the node ID, credential ID, public key, **private key**, and creation time. This record exists for identity-store implementations and must never be logged, returned by an API, or transmitted to a server. + +### `MagicNodeAuthenticationHandler` + +`DelegatingHandler` that buffers the outbound request body, computes its SHA-256, creates a fresh proof, and sends: + +```text +Authorization: MagicNode +X-Magic-Node-Id: +X-Magic-Credential-Id: +``` + +### `MagicHttpClientBuilderExtensions.AddMagicNodeAuthentication` + +```csharp +services.AddHttpClient() + .AddMagicNodeAuthentication("Inventory.Api"); +``` + +Adds request-bound node authentication to a typed or named `HttpClient`. The audience must exactly match what the receiving API expects. + +### `MagicHash` + +- `Sha256Hex(ReadOnlySpan)` returns lowercase SHA-256 hex. +- `EmptySha256` is the hash for an empty request body. + +## Secrets + +### `IMagicSecretProvider` + +```csharp +ValueTask> GetAsync( + string name, + CancellationToken cancellationToken = default); +``` + +Fetches a secret only when requested. The built-in provider requires an active control-plane connection and creates a fresh proof bound to the secret name and endpoint. + +### `MagicSecretLease` + +| Property | Purpose | +|---|---| +| `Value` | Resolved typed secret value. | +| `ExpiresUtc` | Optional server-supplied expiration. | + +Dispose the lease after use. Disposal cannot guarantee erasure of immutable managed strings; prefer byte-oriented custom secret APIs for extremely sensitive material. + +### `IMagicSecretTransport` + +Transport extension point for `MagicSecretRequest` / `MagicSecretResponse`. + +--- + +# MagicSettings.Share package + +## Remote configuration contracts + +### `MagicRemoteValue` + +Represents one remote path value. + +- `From` creates a serialized value. +- `ExplicitNull` explicitly overrides a lower provider with `null`. +- `Durability` controls sticky, refreshable, or expiring behavior. +- `ExpiresUtc` is meaningful for expiring values. + +### `MagicRemoteSnapshot` + +A complete node-specific remote layer. A newer snapshot replaces the previous remote layer wholesale. Omitted paths reveal lower-priority providers. `Empty` represents no remote overrides. + +### `MagicValueState` + +- `Value` +- `Null` + +### `MagicRemoteValueDurability` + +- `Sticky`: survives control-plane outages until replaced. +- `Refreshable`: ordinary refreshable remote value. +- `Expiring`: removed from the effective remote layer after `ExpiresUtc`. + +## Schema and migration contracts + +### `MagicSettingManifestEntry` + +Describes path, CLR type, nullability, sensitivity, remote-override permission, and optional human description. + +### `MagicSettingsSchemaManifest` + +Describes application ID/version, schema version, deterministic schema fingerprint, and all discovered settings entries. + +### `MagicSettingsMigrationReport` + +Contains local safe operations and review items generated while migrating one node. + +### `MagicMigrationReviewItem` + +Contains affected path, operation, severity, reason, and optional proposed replacement path. + +### `MagicMigrationReviewSeverity` + +- `Information` +- `Warning` +- `Destructive` + +## Control-plane contracts + +### `MagicControlPlaneEndpointSource` + +Identifies whether the selected endpoint came from no source, code fallback, persistent settings, environment variable, or runtime override. + +### `MagicResolvedControlPlaneEndpoint` + +Contains the endpoint and source. `None` represents disabled remote synchronization. + +### `MagicControlPlaneState` + +- `Disabled` +- `Configured` +- `Connecting` +- `PendingApproval` +- `Active` +- `Disconnected` +- `Faulted` + +### `MagicControlPlaneTrust` + +Use `SystemTls(authorityId)` for normal platform TLS validation or `Pinned(authorityId, fingerprint)` for public-key fingerprint pinning. `AuthorityId` is also the expected proof audience. + +### `MagicSettingsSyncRequest` + +Carries public identity, request proof, schema manifest, last remote revision, optional migration report, and optional rotation continuity proof. + +### `MagicSettingsSyncResponse` + +Carries state, complete remote snapshot, optional message, and optional suggested polling interval. + +## Authentication contracts + +### `MagicNodeIdentityDescriptor` + +Safe public identity descriptor: node ID, credential ID, credential kind, signature algorithm, public key, fingerprint, and creation time. + +### `MagicAuthenticationRequest` + +Input to `CreateProofAsync`: audience, HTTP method, target URI, body hash, and optional proof lifetime. + +### `MagicAuthenticationProof` + +Signed proof containing version, node and credential IDs, audience, method, normalized target, body hash, issuance, expiration, nonce, and signature. + +A proof is not a reusable API key. It is valid only for the exact request it signs and must be rejected after nonce reuse. + +### `MagicIdentityContinuityProof` + +Binds an old approved public credential to a new public credential using a signature from the old private key. + +### `MagicIdentityChange` + +Describes `Rotated`, `Reset`, or `RecoveredAfterLoss` changes and includes current/previous descriptors plus an optional continuity proof. + +### `MagicIdentityResetRequest` + +Contains a human reason and the required destructive confirmation flag. + +### `MagicProofVerificationRequest` + +Server-side verification context: proof, expected audience, actual method/URI/body hash, and current time. + +### `MagicProofVerificationResult` + +Use `IsValid` and `Error`; helper members `Valid` and `Invalid(error)` create results. + +### `MagicNodeProofCodec` + +Encodes/decodes proofs for the `MagicNode` authorization header and provides Base64URL helpers. Decoding does not verify authenticity; always pass decoded proofs through `MagicNodeProofVerifier`. + +### `MagicSettingsSyncProof.ComputeBodySha256` + +Computes the deterministic hash covered by a synchronization proof, including identity, schema metadata, revision, migration report, and optional continuity proof. + +### `MagicSecretProof.ComputeBodySha256` + +Computes the deterministic body hash for a named secret request. + +--- + +# MagicSettings.Server package + +## Proof verification + +### `MagicNodeProofVerifier` + +```csharp +ValueTask VerifyAsync(MagicProofVerificationRequest request, CancellationToken cancellationToken = default); +ValueTask VerifyEnrollmentAsync(MagicNodeIdentityDescriptor identity, MagicProofVerificationRequest request, CancellationToken cancellationToken = default); +static bool VerifyContinuity(MagicIdentityContinuityProof proof); +``` + +`VerifyAsync` requires a known credential in `Approved` or `Retiring` state. It validates proof version, audience, method, target, body hash, clock bounds, maximum lifetime, signature, and one-time nonce. + +`VerifyEnrollmentAsync` verifies an unknown node using the public key supplied in its identity descriptor before a pending credential record is created. + +`VerifyContinuity` validates that a new credential belongs to the same logical node and was authorized by the old credential. + +### `MagicAspNetProofVerificationExtensions.VerifyHttpRequestAsync` + +Reads an ASP.NET request, decodes the `MagicNode` header, buffers and restores the body stream, computes the actual body hash, reconstructs the request URI, and delegates to `MagicNodeProofVerifier`. + +## Credential storage and lifecycle + +### `MagicRegisteredCredential` + +Stores node ID, credential ID, public key, status, and update time. It never contains a private key. + +### `IMagicCredentialRegistry` + +```csharp +ValueTask FindAsync(Guid nodeId, Guid credentialId, CancellationToken cancellationToken = default); +ValueTask UpsertAsync(MagicRegisteredCredential credential, CancellationToken cancellationToken = default); +``` + +Implement with durable server storage for production. + +### `MagicCredentialAdministrationService` + +- `SetStatusAsync` changes a known credential status. +- `ApproveAsync` marks a credential approved. +- `RevokeAsync` marks a credential revoked. + +Returning `false` means the credential was not found. + +### `MagicCredentialRotationService.ApplyAsync` + +Validates the continuity proof, verifies the previous registered credential, marks it retiring, and stores the new credential as approved or pending according to policy. + +### `MagicCredentialStatus` + +- `Pending` +- `Approved` +- `Retiring` +- `Revoked` + +### `InMemoryMagicCredentialRegistry` + +Testing and single-process demonstration implementation. It is not durable and does not distribute authorization state to other API processes. + +## Replay defense + +### `IMagicReplayCache` + +```csharp +ValueTask TryUseAsync( + Guid credentialId, + string nonce, + DateTimeOffset expiresUtc, + CancellationToken cancellationToken = default); +``` + +Returns `true` exactly once for a credential/nonce pair. Production implementations must be shared or otherwise consistent across all replicas that accept the same credential. + +### `InMemoryMagicReplayCache` + +Suitable for tests or one API process. It cannot prevent replay across multiple replicas. + +## Synchronization storage + +### `MagicNodeRemoteRecord` + +Stores per-node and per-application schema metadata, remote revision/snapshot, pending migration review items, and last-contact time. + +### `IMagicNodeRemoteRecordStore` + +Loads and saves one node/application record. Production implementations should use durable storage and concurrency controls appropriate to their control plane. + +### `InMemoryMagicNodeRemoteRecordStore` + +Testing and demonstration implementation only. + +### `MagicSettingsSyncService.SynchronizeAsync` + +Validates the signed synchronization payload, handles enrollment and optional continuity proofs, returns pending/active/faulted state, stores schema metadata, retains destructive migration review items, and returns the node-specific snapshot. + +The service does not automatically delete remote values merely because a client schema no longer consumes them. + +## Secrets + +### `IMagicSecretResolver` + +```csharp +ValueTask ResolveAsync( + Guid nodeId, + string name, + CancellationToken cancellationToken = default); +``` + +Application-provided server extension point. It decides authorization, storage, auditing, and value retrieval after node proof verification. + +### `MagicSecretService.ResolveAsync` + +Checks that request identity fields match the proof, verifies the request against the expected authority audience, method, URI, and secret-name hash, then delegates to `IMagicSecretResolver`. + +--- + +# Security rules for API consumers + +1. Never log, return, serialize to telemetry, or transmit `MagicStoredNodeIdentity.PrivateKey`. +2. Never treat `MagicAuthenticationProof` as a reusable bearer token. +3. Use a distinct stable audience for each relying API or trust domain. +4. Verify the actual HTTP method, target URI, and body hash at the receiver. +5. Store replay nonces in a cache shared by every replica accepting the same credentials. +6. Reset does not revoke a stolen old credential; server-side revocation remains mandatory. +7. Do not permit a remote settings snapshot to choose its own control-plane endpoint. +8. Do not enable non-loopback HTTP merely to make deployment easier. +9. In-memory server stores are examples and test helpers, not production durability. +10. Treat destructive migration review items as administrative decisions, not automatic cleanup instructions. diff --git a/wiki/configuration.md b/wiki/configuration.md new file mode 100644 index 0000000..64f16f3 --- /dev/null +++ b/wiki/configuration.md @@ -0,0 +1,70 @@ +# Configuration + +## Architecture and ownership + +Every application instance owns its code-defined schema and defaults, persistent settings file, migration chain, installation identity, and effective runtime snapshot. + +Remote communication is always initiated by the application. The server does not need a callback address and never discovers or pushes directly to a node. Two nodes running the same application can use different schema versions and settings. Updating Node A does not migrate Node B. A control-plane implementation may add groups or policies, but those are features above the MagicSettings protocol rather than assumptions inside the client library. + +The server receives a schema manifest during synchronization. It may retain node-specific overrides and migration review items. Client-side removal means “this client no longer consumes this path,” not “destroy the server-side value.” Destructive server storage changes remain an explicit administrative concern. + +## Configuration precedence + +The runtime composes snapshots in this order: + +1. Persistent JSON document. +2. Custom source providers, ordered by provider priority. +3. OS/process environment values using the `MagicSettings__` prefix. +4. Remote in-memory snapshot. + +The last source wins. A complete incoming remote snapshot replaces the previous remote layer; paths absent from the new snapshot expose the next lower source again. The code template generates and reconciles the persistent file. It is not reapplied as a hidden high-priority runtime source. + +`IMagicSettings.Explain(path)` reports which sources contained a path and which one won. Paths registered in `SensitivePaths` are redacted. + +## Paths and environments + +Settings path resolution: + +1. `MagicSettingsOptions.Path`. +2. `MAGICSETTINGS_PATH`. +3. `AppContext.BaseDirectory/appsettings.json`. + +A path ending in `.json` is treated as a complete file path. Any other path is treated as a directory and the configured file name is appended. `AppContext.BaseDirectory` is used instead of the working directory because service managers, tests, containers, and desktop launchers commonly choose different working directories. + +Environment resolution: + +1. Explicit option. +2. `MAGICSETTINGS_ENVIRONMENT`. +3. `DOTNET_ENVIRONMENT`. +4. `ASPNETCORE_ENVIRONMENT`. +5. `Production`. + +Development, Local, and Test use strict array-reconciliation defaults. Production preserves existing ambiguous arrays by default rather than turning a rollout mistake into a fleet-wide startup outage. + +`MagicSettings__Database__Password` maps to `Database:Password`. Environment overrides are transient and never written to the persistent file. External changes to a running process's environment normally require restart; use a watched file, runtime configuration call, or remote provider for live updates. + +The identity file is adjacent to the settings file by default. Set `options.IdentityPath` or `MAGICSETTINGS_IDENTITY_PATH` to place it elsewhere. A directory appends `IdentityFileName`; a file path is used directly. Identity placement is independent from settings regeneration. + +## Reconciliation and arrays + +Reconciliation adds missing object properties from the current template and preserves existing values. Unknown properties are preserved by default. + +Arrays are not merged automatically because they may represent ordered pipelines, sets, operator-curated lists, or keyed objects. Configure the policy for every path whose template and persistent values may differ: + +```csharp +options.ArrayPolicies["AllowedOrigins"] = MagicArrayMergePolicy.Union; +``` + +Policies are `PreserveExisting`, `ReplaceWithTemplate`, `AppendMissing`, and `Union`. In Development, Local, and Test, an ambiguous array throws during startup. In Production, the existing array is preserved by default. + +Writes use a temporary file, backup, and atomic replacement. Runtime parse or validation failures retain the last known good snapshot. + +## Providers and diagnostics + +Implement `IMagicSettingsSourceProvider` for persistent or bulk external sources such as mounted files, a local database, or a vault cache. Providers return a complete path/value snapshot and are applied by priority. + +On-demand secrets are separate. `IMagicSecretProvider.GetAsync` is asynchronous and explicit because synchronous `IConfiguration["Secret"]` access cannot safely perform arbitrary network I/O. + +Standard `IConfiguration` sees the already resolved effective snapshot. It cannot reveal every later semantic use after an application binds or copies a value; use `IMagicSettings.Explain` for source provenance at resolution time. + +Useful operational signals include current settings revision, path and environment, last successful reload, rejected candidate errors, control-plane state and endpoint source, last remote revision, node ID and public credential fingerprint, and pending or revoked credential state. Never log private keys, secret values, complete proofs, or raw enrollment tokens. diff --git a/wiki/control-plane.md b/wiki/control-plane.md new file mode 100644 index 0000000..1a99eaf --- /dev/null +++ b/wiki/control-plane.md @@ -0,0 +1,58 @@ +# Control plane and node identity + +## Control-plane integration + +MagicSettings enables a control-plane relationship but does not implement a universal control plane. The client initiates synchronization and sends public node identity, a request-bound proof, application and schema metadata, the schema manifest, last known remote revision, and optional migration review information. + +The server answers with approval state and a node-specific remote snapshot. The snapshot is an in-memory top-priority layer. It is not copied into OS environment variables and is never serialized into the local file. A server implementation chooses its own storage, approval UI, grouping, policy, encryption-at-rest, and authorization distribution strategy. + +## Endpoint bootstrap + +A control-plane endpoint may be known during startup or discovered later. Default resolution order: + +1. Explicit runtime `ConfigureAsync` call. +2. Dedicated OS environment variable, normally `MAGICSETTINGS_CONTROL_PLANE_ENDPOINT`. +3. A path in the persistent local JSON document, normally `MagicSettings:ControlPlane:Endpoint`. +4. Code fallback. +5. No endpoint; remote synchronization remains disabled. + +Only local bootstrap sources participate. The effective runtime snapshot is deliberately not used because it includes remote overrides. Allowing the current remote authority to overwrite its own endpoint would let it redirect the node's trust relationship. + +Persistent endpoint changes can be detected by the local file watcher. The new authority is authenticated before replacing the old endpoint. A failed transition retains the prior effective snapshot rather than accepting settings from an untrusted endpoint. External environment-variable edits are generally invisible to an already running process; restart or call `ConfigureAsync` for an immediate transition. + +## Identity and enrollment + +MagicSettings creates an ECDSA P-256 installation credential during first initialization. The identity has a stable node ID, credential ID, public key and fingerprint, private key, and creation timestamp. The public descriptor is safe to send during enrollment. The private key remains inside `IMagicNodeIdentityStore` and the signing service. + +The default file store restricts Unix permissions to the current user. It is a portable baseline, not a hardware-backed vault. Implement `IMagicNodeIdentityStore` to use DPAPI, Keychain, TPM, HSM, Kubernetes secrets, or another platform facility. + +If the identity file is lost, a replacement identity is a new unapproved node. A name, hostname, or application ID is not proof that the replacement is the previous node. + +## API authentication proofs + +The same approved node identity can authenticate to APIs other than the control plane. Do not transmit the private key and do not reuse one static signature as a bearer token. + +`IMagicNodeAuthenticator.CreateProofAsync` creates a short-lived signature bound to API audience, HTTP method, normalized target URI, request-body SHA-256, issuance and expiration, one-time nonce, node ID, and credential ID. The receiving API uses `MagicNodeProofVerifier`, a credential registry, and a replay cache. The registry can be populated by an authentication service and cached by each API. + +```csharp +services.AddHttpClient() + .AddMagicNodeAuthentication("MyApi"); +``` + +The handler sends `Authorization: MagicNode `. Capturing a proof does not authorize a different endpoint, body, method, audience, or replay. + +Never add a general-purpose private-key export method for convenience. A component that genuinely needs TLS client-certificate integration should receive a narrowly scoped handler or key-store integration rather than raw exportable bytes. + +## Rotation, reset, and revocation + +`IMagicNodeIdentityManager.RotateAsync` keeps the node ID and creates a new credential ID and keypair. It returns a continuity proof signed by the old credential. `MagicCredentialRotationService` verifies this proof and may mark the old credential `Retiring` while approving or holding the new credential. The client carries the continuity proof on its next normal sync; no server callback is required. + +`ResetAsync` is destructive and requires explicit confirmation. It creates a new node ID and credential and clears the current remote snapshot. There is no continuity claim; the server must approve it again. Use reset when the old private key is lost or suspected compromised. Use rotation for ordinary renewal while the old credential remains trustworthy. + +Local reset does not invalidate a stolen copy of the old key. The authorization server must revoke the old credential, and relying APIs must refresh authorization caches. `MagicCredentialAdministrationService` provides storage-agnostic approve and revoke helpers. Applications should choose an authorization-staleness policy appropriate to their risk. + +## Outages and stale state + +Remote snapshots are sticky by default. Losing WAN connectivity does not automatically erase settings required for LAN operation. MagicSettings retains the last known good remote snapshot, marks synchronization disconnected or faulted, continues using lower layers, retries later, and rejects malformed replacements. + +Individual remote values may be marked `Expiring`. Expiration is appropriate for short-lived grants or tokens, not ordinary hostnames, ports, or offline operating policy. Configuration freshness and credential validity are separate concepts. diff --git a/wiki/getting-started.md b/wiki/getting-started.md new file mode 100644 index 0000000..0d46300 --- /dev/null +++ b/wiki/getting-started.md @@ -0,0 +1,32 @@ +# Getting started + +Call `AddMagicSettingsAsync` immediately after constructing the host builder. The call resolves the persistent path, creates or reconciles the JSON document, runs migrations, creates the installation identity, publishes the effective configuration into the standard .NET configuration chain, and registers background services. + +```csharp +var builder = WebApplication.CreateBuilder(args); +var result = await builder.AddMagicSettingsAsync(args, options => +{ + options.ApplicationId = "Orders.Api"; + options.SchemaVersion = 1; + options.Template = new AppSettings + { + Database = new() { Host = "localhost", Port = 5432 } + }; +}); + +if (result.ShouldExit) +{ + return; +} +``` + +The default persistent path is `AppContext.BaseDirectory/appsettings.json`. `MAGICSETTINGS_PATH` may point to either a JSON file or a directory. + +## Command-line operations + +- `--magic-settings-generate` reconciles the file and exits. +- `--magic-settings-force-generate` replaces the persistent document from the template and exits. A backup is retained by the atomic writer. +- `--magic-settings-validate` loads, binds, validates, and exits. +- `--magic-settings-print-path` prints the resolved file path and exits. + +Destructive settings regeneration is separate from identity reset. Replacing `appsettings.json` does not change the node credential. diff --git a/wiki/migrations.md b/wiki/migrations.md new file mode 100644 index 0000000..f2af47c --- /dev/null +++ b/wiki/migrations.md @@ -0,0 +1,20 @@ +# Migrations and collection reconciliation + +Use explicit sequential migrations for breaking changes. A migration declares `FromVersion`, `ToVersion`, and mutates a `JsonObject` through `MagicMigrationContext`. + +```csharp +public sealed class RenameDatabaseUser : IMagicSettingsMigration +{ + public int FromVersion => 1; + public int ToVersion => 2; + + public void Apply(JsonObject document, MagicMigrationContext context) + { + context.Rename(document, "Database:Username", "Database:User"); + } +} +``` + +Missing steps, non-advancing versions, downgrade attempts, and destination collisions stop startup. The original document is not replaced until the complete candidate succeeds. + +`context.Remove(...)` removes the path from the local document but creates a destructive review item for remote storage. The server helper retains that review item rather than deleting a stored secret automatically. diff --git a/wiki/secrets-and-security.md b/wiki/secrets-and-security.md new file mode 100644 index 0000000..769ce87 --- /dev/null +++ b/wiki/secrets-and-security.md @@ -0,0 +1,19 @@ +# Secrets and security + +## Secrets + +Bulk provider snapshots and on-demand secrets are separate abstractions. Use `IMagicSecretProvider.GetAsync` for a secret that should be fetched only when needed. The returned lease may include expiration metadata and should be disposed when the caller is finished. + +MagicSettings cannot guarantee erasure of managed strings. For extremely sensitive values, prefer byte buffers and platform key stores that support non-exportable keys. Remote or environment secrets are not written into the persistent JSON file. Mark paths sensitive so diagnostics redact them. + +The built-in HTTP control-plane transport implements `IMagicSecretTransport`, so normal one-call initialization registers `IMagicSecretProvider` automatically. A custom transport can opt in by implementing the same interface. + +Each request is bound to authority audience, secret endpoint, requested name, short lifetime, and one-time nonce. The server uses `MagicSecretService` plus an application-provided `IMagicSecretResolver`; MagicSettings does not dictate server storage. + +## Security boundaries + +MagicSettings can keep transient values out of the file, authenticate possession of a node credential, bind signatures to exact requests, reject expired or replayed proofs, support rotation and revocation records, pin a control-plane public-key fingerprint, and preserve destructive migration effects for review. + +It cannot protect a secret from an attacker controlling the process or machine, securely erase arbitrary managed strings, make an untrusted endpoint safe merely because it uses HTTPS, revoke a stolen key without server-side revocation distribution, or infer whether separately installed nodes should share policy. + +The default file identity store is intentionally replaceable. High-security deployments should use an OS or hardware-backed store with non-exportable keys. diff --git a/wiki/testing.md b/wiki/testing.md new file mode 100644 index 0000000..d2505c0 --- /dev/null +++ b/wiki/testing.md @@ -0,0 +1,11 @@ +# Testing + +The test suite covers endpoint bootstrap precedence, the local-only endpoint rule, generation and non-destructive reconciliation, strict development array policy, remote-over-environment-over-file precedence, non-persistence of remote values, stable identity, rotation continuity, destructive reset confirmation, audience/method/URI/body proof binding, replay rejection, self-signed enrollment, rotation through normal synchronization, revocation, asynchronous secret proof verification, endpoint hijack prevention, migrations, and retention of destructive review items. + +Run: + +```bash +dotnet restore MagicSettings.sln +dotnet build MagicSettings.sln --configuration Release --no-restore +dotnet test MagicSettings.sln --configuration Release --no-build +```