save progress

This commit is contained in:
StellaOps Bot
2026-01-02 15:52:31 +02:00
parent 2dec7e6a04
commit f46bde5575
174 changed files with 20793 additions and 8307 deletions

View File

@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
@@ -116,12 +117,12 @@ public static class OfflineVerificationPolicyLoader
return JsonValue.Create(boolean);
}
if (long.TryParse(scalar.Value, out var integer))
if (long.TryParse(scalar.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var integer))
{
return JsonValue.Create(integer);
}
if (decimal.TryParse(scalar.Value, out var decimalValue))
if (decimal.TryParse(scalar.Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var decimalValue))
{
return JsonValue.Create(decimalValue);
}
@@ -129,4 +130,3 @@ public static class OfflineVerificationPolicyLoader
return JsonValue.Create(scalar.Value);
}
}

View File

@@ -27,7 +27,7 @@ public sealed class EvidenceGraph
/// Generation timestamp in ISO 8601 UTC format.
/// </summary>
[JsonPropertyName("generatedAt")]
public string GeneratedAt { get; init; } = DateTimeOffset.UtcNow.ToString("O");
public string GeneratedAt { get; init; } = DateTimeOffset.UnixEpoch.ToString("O");
/// <summary>
/// Generator tool identifier.
@@ -128,6 +128,9 @@ public sealed record AttestationNodeRef
[JsonPropertyName("path")]
public required string Path { get; init; }
[JsonPropertyName("contentHash")]
public required string ContentHash { get; init; }
[JsonPropertyName("signatureValid")]
public bool SignatureValid { get; init; }
@@ -237,6 +240,7 @@ public sealed class EvidenceGraphSerializer
Generator = graph.Generator,
Nodes = graph.Nodes
.OrderBy(n => n.Id, StringComparer.Ordinal)
.Select(ApplyDeterministicOrdering)
.ToList(),
Edges = graph.Edges
.OrderBy(e => e.Source, StringComparer.Ordinal)
@@ -303,4 +307,27 @@ public sealed class EvidenceGraphSerializer
return (graph, hashValid);
}
private static EvidenceNode ApplyDeterministicOrdering(EvidenceNode node)
{
return node with
{
Sboms = node.Sboms?
.OrderBy(s => s.ContentHash, StringComparer.Ordinal)
.ThenBy(s => s.Path, StringComparer.Ordinal)
.ThenBy(s => s.Format, StringComparer.Ordinal)
.ToList(),
Attestations = node.Attestations?
.OrderBy(a => a.ContentHash, StringComparer.Ordinal)
.ThenBy(a => a.Path, StringComparer.Ordinal)
.ThenBy(a => a.PredicateType, StringComparer.Ordinal)
.ToList(),
VexStatements = node.VexStatements?
.OrderBy(v => v.VulnerabilityId, StringComparer.Ordinal)
.ThenBy(v => v.Source, StringComparer.Ordinal)
.ThenBy(v => v.Status, StringComparer.Ordinal)
.ThenBy(v => v.Justification ?? string.Empty, StringComparer.Ordinal)
.ToList()
};
}
}

View File

@@ -182,6 +182,7 @@ public sealed class EvidenceReconciler : IEvidenceReconciler
{
PredicateType = a.PredicateType,
Path = a.FilePath,
ContentHash = a.ContentHash,
SignatureValid = a.SignatureVerified,
RekorVerified = a.TlogVerified
}).ToList(),

View File

@@ -4,6 +4,7 @@
// Part of Step 2: Evidence Collection (Task T5)
// =============================================================================
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
@@ -121,7 +122,7 @@ public sealed class CycloneDxParser : ISbomParser
{
if (metadataProp.TryGetProperty("timestamp", out var timestampProp))
{
if (DateTimeOffset.TryParse(timestampProp.GetString(), out var parsed))
if (TryParseTimestamp(timestampProp.GetString(), out var parsed))
{
createdAt = parsed;
}
@@ -214,21 +215,7 @@ public sealed class CycloneDxParser : ISbomParser
}
// Determine primary digest (prefer SHA-256)
string? digest = null;
if (hashes.TryGetValue("SHA-256", out var sha256))
{
digest = NormalizeDigest("sha256:" + sha256);
}
else if (hashes.TryGetValue("SHA256", out sha256))
{
digest = NormalizeDigest("sha256:" + sha256);
}
else if (hashes.Count > 0)
{
// Use first available hash
var first = hashes.First();
digest = NormalizeDigest($"{first.Key.ToLowerInvariant().Replace("-", "")}:{first.Value}");
}
var digest = TrySelectSha256Digest(hashes);
// If no digest, this component can't be indexed by digest
if (string.IsNullOrEmpty(digest))
@@ -333,4 +320,24 @@ public sealed class CycloneDxParser : ISbomParser
{
return ArtifactIndex.NormalizeDigest(digest);
}
private static bool TryParseTimestamp(string? value, out DateTimeOffset timestamp)
{
timestamp = default;
return !string.IsNullOrWhiteSpace(value) &&
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out timestamp);
}
private static string? TrySelectSha256Digest(IReadOnlyDictionary<string, string> hashes)
{
foreach (var key in new[] { "SHA-256", "SHA256", "sha256" })
{
if (hashes.TryGetValue(key, out var sha256))
{
return NormalizeDigest("sha256:" + sha256);
}
}
return null;
}
}

View File

@@ -174,10 +174,14 @@ public sealed record InTotoSubject
/// </summary>
public string? GetSha256Digest()
{
if (Digest.TryGetValue("sha256", out var hash))
foreach (var (key, value) in Digest)
{
return "sha256:" + hash.ToLowerInvariant();
if (string.Equals(key, "sha256", StringComparison.OrdinalIgnoreCase))
{
return "sha256:" + value.ToLowerInvariant();
}
}
return null;
}
}

View File

@@ -4,6 +4,7 @@
// Part of Step 2: Evidence Collection (Task T5)
// =============================================================================
using System.Globalization;
using System.Text.Json;
namespace StellaOps.AirGap.Importer.Reconciliation.Parsers;
@@ -114,7 +115,7 @@ public sealed class SpdxParser : ISbomParser
if (root.TryGetProperty("creationInfo", out var creationInfoProp) &&
creationInfoProp.TryGetProperty("created", out var createdProp))
{
if (DateTimeOffset.TryParse(createdProp.GetString(), out var parsed))
if (TryParseTimestamp(createdProp.GetString(), out var parsed))
{
createdAt = parsed;
}
@@ -224,17 +225,7 @@ public sealed class SpdxParser : ISbomParser
}
// Determine primary digest (prefer SHA256)
string? digest = null;
if (hashes.TryGetValue("SHA256", out var sha256))
{
digest = NormalizeDigest("sha256:" + sha256);
}
else if (hashes.Count > 0)
{
// Use first available hash
var first = hashes.First();
digest = NormalizeDigest($"{first.Key.ToLowerInvariant()}:{first.Value}");
}
var digest = TrySelectSha256Digest(hashes);
// If no digest, this package can't be indexed by digest
if (string.IsNullOrEmpty(digest))
@@ -302,4 +293,24 @@ public sealed class SpdxParser : ISbomParser
{
return ArtifactIndex.NormalizeDigest(digest);
}
private static bool TryParseTimestamp(string? value, out DateTimeOffset timestamp)
{
timestamp = default;
return !string.IsNullOrWhiteSpace(value) &&
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out timestamp);
}
private static string? TrySelectSha256Digest(IReadOnlyDictionary<string, string> hashes)
{
foreach (var key in new[] { "SHA256", "SHA-256", "sha256" })
{
if (hashes.TryGetValue(key, out var sha256))
{
return NormalizeDigest("sha256:" + sha256);
}
}
return null;
}
}

View File

@@ -5,8 +5,11 @@ using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Asn1.X9;
using StellaOps.Attestor.Envelope;
using StellaOps.Cryptography;
using StellaOps.AirGap.Importer.Validation;
using AttestorDsseEnvelope = StellaOps.Attestor.Envelope.DsseEnvelope;
using AttestorDsseSignature = StellaOps.Attestor.Envelope.DsseSignature;
using StellaOps.Attestor.Envelope;
namespace StellaOps.AirGap.Importer.Reconciliation.Signing;
@@ -45,10 +48,10 @@ internal sealed class EvidenceGraphDsseSigner
var envelopeKey = LoadEcdsaEnvelopeKey(signingPrivateKeyPemPath, signingKeyId);
var signature = SignDeterministicEcdsa(pae, signingPrivateKeyPemPath, envelopeKey.AlgorithmId);
var envelope = new DsseEnvelope(
var envelope = new AttestorDsseEnvelope(
EvidenceGraphPayloadType,
payloadBytes,
signatures: [DsseSignature.FromBytes(signature, envelopeKey.KeyId)],
signatures: [AttestorDsseSignature.FromBytes(signature, envelopeKey.KeyId)],
payloadContentType: "application/json");
var serialized = DsseEnvelopeSerializer.Serialize(
@@ -177,25 +180,3 @@ internal sealed class EvidenceGraphDsseSigner
/// </summary>
private sealed record EnvelopeKey(string AlgorithmId, string KeyId);
}
internal static class DssePreAuthenticationEncoding
{
private const string Prefix = "DSSEv1";
public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
{
if (string.IsNullOrWhiteSpace(payloadType))
{
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
}
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
var headerBytes = Encoding.UTF8.GetBytes(header);
var buffer = new byte[headerBytes.Length + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
return buffer;
}
}

View File

@@ -107,10 +107,12 @@ public sealed class SourcePrecedenceLattice
nameof(statements));
}
// Sort by precedence (descending), then by timestamp (descending)
// Sort by precedence (descending), timestamp (descending), then status priority (configurable).
var winner = statementList
.OrderByDescending(s => (int)s.Source)
.ThenByDescending(s => s.Timestamp ?? DateTimeOffset.MinValue)
.ThenByDescending(s => GetStatusPriority(s.Status))
.ThenBy(s => s.DocumentRef ?? string.Empty, StringComparer.Ordinal)
.First();
return winner;
@@ -184,18 +186,8 @@ public sealed class SourcePrecedenceLattice
}
// Same precedence and timestamp - true conflict
// Use status priority: NotAffected > Fixed > UnderInvestigation > Affected > Unknown
var statusPriority = new Dictionary<VexStatus, int>
{
[VexStatus.NotAffected] = 5,
[VexStatus.Fixed] = 4,
[VexStatus.UnderInvestigation] = 3,
[VexStatus.Affected] = 2,
[VexStatus.Unknown] = 1
};
var aPriority = statusPriority.GetValueOrDefault(a.Status, 0);
var bPriority = statusPriority.GetValueOrDefault(b.Status, 0);
var aPriority = GetStatusPriority(a.Status);
var bPriority = GetStatusPriority(b.Status);
if (aPriority != bPriority)
{
@@ -218,6 +210,29 @@ public sealed class SourcePrecedenceLattice
Winner: fallbackWinner,
Reason: "Deterministic fallback (document ref ordering)");
}
private int GetStatusPriority(VexStatus status)
{
return _config.PreferRestrictive
? status switch
{
VexStatus.Affected => 5,
VexStatus.UnderInvestigation => 4,
VexStatus.Fixed => 3,
VexStatus.NotAffected => 2,
VexStatus.Unknown => 1,
_ => 0
}
: status switch
{
VexStatus.NotAffected => 5,
VexStatus.Fixed => 4,
VexStatus.UnderInvestigation => 3,
VexStatus.Affected => 2,
VexStatus.Unknown => 1,
_ => 0
};
}
}
/// <summary>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0026-M | DONE | Maintainability audit for StellaOps.AirGap.Importer. |
| AUDIT-0026-T | DONE | Test coverage audit for StellaOps.AirGap.Importer. |
| AUDIT-0026-A | TODO | Pending approval for changes. |
| AUDIT-0026-A | DOING | Pending approval for changes. |

View File

@@ -0,0 +1,25 @@
using System.Text;
namespace StellaOps.AirGap.Importer.Validation;
internal static class DssePreAuthenticationEncoding
{
private const string Prefix = "DSSEv1";
public static byte[] Encode(string payloadType, ReadOnlySpan<byte> payload)
{
if (string.IsNullOrWhiteSpace(payloadType))
{
throw new ArgumentException("payloadType must be provided.", nameof(payloadType));
}
var payloadTypeByteCount = Encoding.UTF8.GetByteCount(payloadType);
var header = $"{Prefix} {payloadTypeByteCount} {payloadType} {payload.Length} ";
var headerBytes = Encoding.UTF8.GetBytes(header);
var buffer = new byte[headerBytes.Length + payload.Length];
headerBytes.CopyTo(buffer.AsSpan());
payload.CopyTo(buffer.AsSpan(headerBytes.Length));
return buffer;
}
}

View File

@@ -1,4 +1,3 @@
using System.Text;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.Cryptography;
@@ -12,10 +11,10 @@ namespace StellaOps.AirGap.Importer.Validation;
/// </summary>
public sealed class DsseVerifier
{
private const string PaePrefix = "DSSEv1";
private readonly ICryptoProviderRegistry _cryptoRegistry;
private readonly TimeProvider _timeProvider;
public DsseVerifier(ICryptoProviderRegistry? cryptoRegistry = null)
public DsseVerifier(ICryptoProviderRegistry? cryptoRegistry = null, TimeProvider? timeProvider = null)
{
if (cryptoRegistry is null)
{
@@ -27,6 +26,8 @@ public sealed class DsseVerifier
{
_cryptoRegistry = cryptoRegistry;
}
_timeProvider = timeProvider ?? TimeProvider.System;
}
public BundleValidationResult Verify(DsseEnvelope envelope, TrustRootConfig trustRoots, ILogger? logger = null)
@@ -41,35 +42,86 @@ public sealed class DsseVerifier
return BundleValidationResult.Failure("trust-roots-required");
}
if (!IsAlgorithmAllowed(trustRoots.AllowedSignatureAlgorithms))
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code} allowed_algorithms={allowed_algorithms}",
"ALGORITHM_NOT_ALLOWED",
trustRoots.AllowedSignatureAlgorithms.Count);
return BundleValidationResult.Failure("signature-algorithm-disallowed");
}
if (!IsWithinTrustWindow(trustRoots, _timeProvider.GetUtcNow(), out var windowReason))
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code} trust_window={trust_window}",
"TRUST_WINDOW_INVALID",
windowReason);
return BundleValidationResult.Failure("trust-window-invalid");
}
logger?.LogDebug(
"offlinekit.dsse.verify start payload_type={payload_type} signatures={signatures} public_keys={public_keys}",
envelope.PayloadType,
envelope.Signatures.Count,
trustRoots.PublicKeys.Count);
if (!TryDecodeBase64(envelope.Payload, out var payloadBytes))
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code}",
"PAYLOAD_BASE64_INVALID");
return BundleValidationResult.Failure("payload-base64-invalid");
}
var fingerprints = new HashSet<string>(trustRoots.TrustedKeyFingerprints, StringComparer.OrdinalIgnoreCase);
var signatureKeyIds = envelope.Signatures
.Where(sig => !string.IsNullOrWhiteSpace(sig.KeyId))
.Select(sig => sig.KeyId!)
.ToList();
if (signatureKeyIds.Count == 0)
{
logger?.LogWarning(
"offlinekit.dsse.verify failed reason_code={reason_code}",
"SIGNATURE_KEYID_MISSING");
return BundleValidationResult.Failure("signature-keyid-missing");
}
var pae = DssePreAuthenticationEncoding.Encode(envelope.PayloadType, payloadBytes);
foreach (var signature in envelope.Signatures)
{
if (string.IsNullOrWhiteSpace(signature.KeyId))
{
continue;
}
if (!trustRoots.PublicKeys.TryGetValue(signature.KeyId, out var keyBytes))
{
continue;
}
var fingerprint = ComputeFingerprint(keyBytes);
if (!trustRoots.TrustedKeyFingerprints.Contains(fingerprint))
if (!fingerprints.Contains(fingerprint))
{
continue;
}
var pae = BuildPreAuthEncoding(envelope.PayloadType, envelope.Payload);
if (TryVerifyRsaPss(keyBytes, pae, signature.Signature))
if (!TryDecodeBase64(signature.Signature, out var sigBytes))
{
continue;
}
if (TryVerifyRsaPss(keyBytes, pae, sigBytes))
{
logger?.LogInformation(
"offlinekit.dsse.verify succeeded key_id={key_id} fingerprint={fingerprint} payload_type={payload_type}",
signature.KeyId,
fingerprint,
envelope.PayloadType);
return BundleValidationResult.Success("dsse-signature-verified");
}
return BundleValidationResult.Success("dsse-signature-verified");
}
}
logger?.LogWarning(
@@ -80,31 +132,7 @@ public sealed class DsseVerifier
return BundleValidationResult.Failure("dsse-signature-untrusted-or-invalid");
}
private static byte[] BuildPreAuthEncoding(string payloadType, string payloadBase64)
{
var payloadBytes = Convert.FromBase64String(payloadBase64);
var parts = new[]
{
PaePrefix,
payloadType,
Encoding.UTF8.GetString(payloadBytes)
};
var paeBuilder = new StringBuilder();
paeBuilder.Append("PAE:");
paeBuilder.Append(parts.Length);
foreach (var part in parts)
{
paeBuilder.Append(' ');
paeBuilder.Append(part.Length);
paeBuilder.Append(' ');
paeBuilder.Append(part);
}
return Encoding.UTF8.GetBytes(paeBuilder.ToString());
}
private bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, string signatureBase64)
private bool TryVerifyRsaPss(byte[] publicKey, byte[] pae, byte[] signatureBytes)
{
try
{
@@ -112,8 +140,7 @@ public sealed class DsseVerifier
var verifier = _cryptoRegistry.ResolveOrThrow(CryptoCapability.Verification, "PS256")
.CreateEphemeralVerifier("PS256", publicKey);
var sig = Convert.FromBase64String(signatureBase64);
var result = verifier.VerifyAsync(pae, sig).GetAwaiter().GetResult();
var result = verifier.VerifyAsync(pae, signatureBytes).GetAwaiter().GetResult();
return result;
}
catch
@@ -128,5 +155,57 @@ public sealed class DsseVerifier
var hash = hasherResolution.Hasher.ComputeHash(publicKey);
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
private static bool TryDecodeBase64(string value, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(value);
return true;
}
catch
{
bytes = Array.Empty<byte>();
return false;
}
}
private static bool IsAlgorithmAllowed(IReadOnlyCollection<string> allowedAlgorithms)
{
if (allowedAlgorithms.Count == 0)
{
return true;
}
foreach (var algorithm in allowedAlgorithms)
{
if (string.Equals(algorithm, "PS256", StringComparison.OrdinalIgnoreCase) ||
string.Equals(algorithm, "RSASSA-PSS-SHA256", StringComparison.OrdinalIgnoreCase) ||
string.Equals(algorithm, "RSA-PSS-SHA256", StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static bool IsWithinTrustWindow(TrustRootConfig trustRoots, DateTimeOffset nowUtc, out string reason)
{
reason = string.Empty;
if (trustRoots.NotBeforeUtc is { } notBefore && nowUtc < notBefore)
{
reason = "not-before";
return false;
}
if (trustRoots.NotAfterUtc is { } notAfter && nowUtc > notAfter)
{
reason = "not-after";
return false;
}
return true;
}
}

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Importer.Contracts;
using StellaOps.AirGap.Importer.Quarantine;
@@ -99,6 +100,38 @@ public sealed class ImportValidator
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
var expectedMerkleRoot = TryExtractManifestMerkleRoot(request.ManifestJson, out var manifestError);
if (string.IsNullOrWhiteSpace(expectedMerkleRoot))
{
var failed = BundleValidationResult.Failure(manifestError ?? "merkle-root-missing");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"MERKLE_ROOT_MISSING",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
if (!string.Equals(expectedMerkleRoot, merkleRoot, StringComparison.OrdinalIgnoreCase))
{
var failed = BundleValidationResult.Failure($"merkle-root-mismatch:expected={expectedMerkleRoot}:actual={merkleRoot}");
verificationLog.Add(failed.Reason);
_logger.LogWarning(
"offlinekit.import.validation failed tenant_id={tenant_id} bundle_type={bundle_type} bundle_digest={bundle_digest} reason_code={reason_code} reason_message={reason_message}",
request.TenantId,
request.BundleType,
request.BundleDigest,
"MERKLE_ROOT_MISMATCH",
failed.Reason);
await TryQuarantineAsync(request, failed, verificationLog, cancellationToken).ConfigureAwait(false);
return failed;
}
verificationLog.Add($"merkle:{merkleRoot}");
var rotationResult = _rotation.Validate(request.TrustStore.ActiveKeys, request.TrustStore.PendingKeys, request.ApproverIds);
@@ -279,6 +312,48 @@ public sealed class ImportValidator
request.BundlePath);
}
}
private static string? TryExtractManifestMerkleRoot(string? manifestJson, out string? errorReason)
{
errorReason = null;
if (string.IsNullOrWhiteSpace(manifestJson))
{
errorReason = "manifest-missing";
return null;
}
try
{
using var doc = JsonDocument.Parse(manifestJson);
if (TryGetString(doc.RootElement, "merkleRoot", out var merkleRoot) ||
TryGetString(doc.RootElement, "merkle_root", out merkleRoot))
{
return merkleRoot;
}
}
catch (JsonException ex)
{
errorReason = $"manifest-json-invalid:{ex.GetType().Name.ToLowerInvariant()}";
return null;
}
errorReason = "merkle-root-missing";
return null;
}
private static bool TryGetString(JsonElement element, string propertyName, out string? value)
{
if (element.TryGetProperty(propertyName, out var property) &&
property.ValueKind == JsonValueKind.String)
{
value = property.GetString();
return !string.IsNullOrWhiteSpace(value);
}
value = null;
return false;
}
}
public sealed record ImportValidationRequest(

View File

@@ -32,15 +32,31 @@ public sealed class MerkleRootCalculator
private static byte[] HashLeaf(NamedStream entry)
{
using var sha256 = SHA256.Create();
using var buffer = new MemoryStream();
entry.Stream.Seek(0, SeekOrigin.Begin);
entry.Stream.CopyTo(buffer);
var contentHash = sha256.ComputeHash(buffer.ToArray());
var contentHash = ComputeContentHash(sha256, entry.Stream);
var leafBytes = Encoding.UTF8.GetBytes(entry.Path.ToLowerInvariant() + ":" + Convert.ToHexString(contentHash).ToLowerInvariant());
return SHA256.HashData(leafBytes);
}
private static byte[] ComputeContentHash(HashAlgorithm hasher, Stream stream)
{
var canSeek = stream.CanSeek;
var originalPosition = canSeek ? stream.Position : 0;
if (canSeek)
{
stream.Seek(0, SeekOrigin.Begin);
}
var hash = hasher.ComputeHash(stream);
if (canSeek)
{
stream.Seek(originalPosition, SeekOrigin.Begin);
}
return hash;
}
private static IEnumerable<byte[]> Pairwise(IReadOnlyList<byte[]> nodes)
{
for (var i = 0; i < nodes.Count; i += 2)

View File

@@ -399,7 +399,7 @@ public static class RekorOfflineReceiptVerifier
.Select(static line => line.TrimEnd())
.ToList();
// Extract signatures first (note format: "— origin base64sig", or "sig <base64>").
// Extract signatures first (note format: em-dash prefix or "sig <base64>").
var signatures = new List<byte[]>();
foreach (var line in lines)
{
@@ -409,7 +409,7 @@ public static class RekorOfflineReceiptVerifier
continue;
}
if (trimmed.StartsWith("—", StringComparison.Ordinal) || trimmed.StartsWith("--", StringComparison.OrdinalIgnoreCase))
if (LooksLikeDashSignature(trimmed) || trimmed.StartsWith("--", StringComparison.OrdinalIgnoreCase))
{
var token = trimmed.Split(' ', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
if (!string.IsNullOrWhiteSpace(token) && TryDecodeBase64(token, out var sigBytes))
@@ -486,7 +486,7 @@ public static class RekorOfflineReceiptVerifier
private static bool LooksLikeSignatureLine(string trimmedLine)
{
if (trimmedLine.StartsWith("—", StringComparison.Ordinal))
if (LooksLikeDashSignature(trimmedLine))
{
return true;
}
@@ -505,6 +505,11 @@ public static class RekorOfflineReceiptVerifier
return false;
}
private static bool LooksLikeDashSignature(string trimmedLine)
{
return trimmedLine.Length > 0 && trimmedLine[0] == '\u2014';
}
private static bool TryDecodeBase64(string token, out byte[] bytes)
{
try

View File

@@ -61,6 +61,28 @@ public sealed class HttpClientUsageAnalyzerTests
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DoesNotReportDiagnostic_ForTestingAssemblyNames()
{
const string source = """
using System.Net.Http;
namespace Sample.App;
public sealed class Demo
{
public void Run()
{
var client = new HttpClient();
}
}
""";
var diagnostics = await AnalyzeAsync(source, assemblyName: "Sample.App.Testing");
Assert.DoesNotContain(diagnostics, d => d.Id == HttpClientUsageAnalyzer.DiagnosticId);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_RewritesToFactoryCall()
@@ -88,7 +110,45 @@ public sealed class HttpClientUsageAnalyzerTests
{
public void Run()
{
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
}
}
""";
var updated = await ApplyCodeFixAsync(source, assemblyName: "Sample.Service");
Assert.Equal(expected.ReplaceLineEndings(), updated.ReplaceLineEndings());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CodeFix_PreservesHttpClientArguments()
{
const string source = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var handler = new HttpClientHandler();
var client = new HttpClient(handler, disposeHandler: false);
}
}
""";
const string expected = """
using System.Net.Http;
namespace Sample.Service;
public sealed class Demo
{
public void Run()
{
var handler = new HttpClientHandler();
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"), clientFactory: () => new global::System.Net.Http.HttpClient(handler, disposeHandler: false));
}
}
""";
@@ -183,6 +243,9 @@ public sealed class HttpClientUsageAnalyzerTests
{
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request)
=> throw new System.NotImplementedException();
public static System.Net.Http.HttpClient Create(IEgressPolicy egressPolicy, EgressRequest request, System.Func<System.Net.Http.HttpClient> clientFactory)
=> throw new System.NotImplementedException();
}
}
""";

View File

@@ -303,7 +303,7 @@ public sealed class PolicyAnalyzerRoslynTests
{
public void Run()
{
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
var client = global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(egressPolicy: default(global::StellaOps.AirGap.Policy.IEgressPolicy) /* TODO: provide IEgressPolicy instance */, request: new global::StellaOps.AirGap.Policy.EgressRequest(component: "REPLACE_COMPONENT", destination: new global::System.Uri("https://replace-with-endpoint"), intent: "REPLACE_INTENT"));
}
}
""";

View File

@@ -54,8 +54,14 @@ public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
return;
}
var httpClientSymbol = context.Compilation.GetTypeByMetadataName(HttpClientMetadataName);
if (httpClientSymbol is null)
{
return;
}
var createdType = creation.Type;
if (createdType is null || !string.Equals(createdType.ToDisplayString(), HttpClientMetadataName, StringComparison.Ordinal))
if (createdType is null || !SymbolEqualityComparer.Default.Equals(createdType, httpClientSymbol))
{
return;
}
@@ -88,7 +94,9 @@ public sealed class HttpClientUsageAnalyzer : DiagnosticAnalyzer
return true;
}
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase))
if (assemblyName.EndsWith(".Tests", StringComparison.OrdinalIgnoreCase) ||
assemblyName.EndsWith(".Test", StringComparison.OrdinalIgnoreCase) ||
assemblyName.EndsWith(".Testing", StringComparison.OrdinalIgnoreCase))
{
return true;
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
@@ -59,13 +60,7 @@ public sealed class HttpClientUsageCodeFixProvider : CodeFixProvider
private static async Task<Document> ReplaceWithFactoryCallAsync(Document document, ObjectCreationExpressionSyntax creation, CancellationToken cancellationToken)
{
var replacementExpression = SyntaxFactory.ParseExpression(
"global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create(" +
"egressPolicy: /* TODO: provide IEgressPolicy instance */, " +
"request: new global::StellaOps.AirGap.Policy.EgressRequest(" +
"component: \"REPLACE_COMPONENT\", " +
"destination: new global::System.Uri(\"https://replace-with-endpoint\"), " +
"intent: \"REPLACE_INTENT\"))");
var replacementExpression = BuildReplacementExpression(creation);
var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
if (root is null)
@@ -76,4 +71,55 @@ public sealed class HttpClientUsageCodeFixProvider : CodeFixProvider
var updatedRoot = root.ReplaceNode(creation, replacementExpression.WithTriviaFrom(creation));
return document.WithSyntaxRoot(updatedRoot);
}
private static ExpressionSyntax BuildReplacementExpression(ObjectCreationExpressionSyntax creation)
{
var requestExpression = SyntaxFactory.ParseExpression(
"new global::StellaOps.AirGap.Policy.EgressRequest(" +
"component: \"REPLACE_COMPONENT\", " +
"destination: new global::System.Uri(\"https://replace-with-endpoint\"), " +
"intent: \"REPLACE_INTENT\")");
var egressPolicyExpression = SyntaxFactory.ParseExpression(
"default(global::StellaOps.AirGap.Policy.IEgressPolicy)");
var arguments = new List<ArgumentSyntax>
{
SyntaxFactory.Argument(egressPolicyExpression)
.WithNameColon(SyntaxFactory.NameColon("egressPolicy"))
.WithTrailingTrivia(
SyntaxFactory.Space,
SyntaxFactory.Comment("/* TODO: provide IEgressPolicy instance */")),
SyntaxFactory.Argument(requestExpression)
.WithNameColon(SyntaxFactory.NameColon("request"))
};
if (ShouldUseClientFactory(creation))
{
var clientFactoryLambda = SyntaxFactory.ParenthesizedLambdaExpression(
SyntaxFactory.ParameterList(),
CreateHttpClientExpression(creation));
arguments.Add(
SyntaxFactory.Argument(clientFactoryLambda)
.WithNameColon(SyntaxFactory.NameColon("clientFactory")));
}
return SyntaxFactory.InvocationExpression(
SyntaxFactory.ParseExpression("global::StellaOps.AirGap.Policy.EgressHttpClientFactory.Create"))
.WithArgumentList(SyntaxFactory.ArgumentList(SyntaxFactory.SeparatedList(arguments)));
}
private static bool ShouldUseClientFactory(ObjectCreationExpressionSyntax creation)
=> (creation.ArgumentList?.Arguments.Count ?? 0) > 0 || creation.Initializer is not null;
private static ObjectCreationExpressionSyntax CreateHttpClientExpression(ObjectCreationExpressionSyntax creation)
{
var httpClientType = SyntaxFactory.ParseTypeName("global::System.Net.Http.HttpClient");
var arguments = creation.ArgumentList ?? SyntaxFactory.ArgumentList();
return SyntaxFactory.ObjectCreationExpression(httpClientType)
.WithArgumentList(arguments)
.WithInitializer(creation.Initializer);
}
}

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0031-M | DONE | Maintainability audit for StellaOps.AirGap.Policy.Analyzers. |
| AUDIT-0031-T | DONE | Test coverage audit for StellaOps.AirGap.Policy.Analyzers. |
| AUDIT-0031-A | TODO | Pending approval for changes. |
| AUDIT-0031-A | DONE | Applied analyzer symbol match, test assembly exemptions, and code-fix preservation. |

View File

@@ -26,6 +26,35 @@ public static class EgressHttpClientFactory
return client;
}
/// <summary>
/// Creates an <see cref="HttpClient"/> from a caller-provided factory after validating the supplied egress request.
/// </summary>
/// <param name="egressPolicy">The policy used to validate outbound requests.</param>
/// <param name="request">Describes the destination and intent for the outbound call.</param>
/// <param name="clientFactory">Factory used to supply a configured client (for example, from IHttpClientFactory).</param>
/// <param name="configure">Optional configuration hook applied to the newly created client.</param>
/// <returns>An <see cref="HttpClient"/> that has been pre-authorised by the policy.</returns>
public static HttpClient Create(
IEgressPolicy egressPolicy,
EgressRequest request,
Func<HttpClient> clientFactory,
Action<HttpClient>? configure = null)
{
ArgumentNullException.ThrowIfNull(egressPolicy);
ArgumentNullException.ThrowIfNull(clientFactory);
egressPolicy.EnsureAllowed(request);
var client = clientFactory();
if (client is null)
{
throw new InvalidOperationException("EgressHttpClientFactory received a null HttpClient from the factory.");
}
configure?.Invoke(client);
return client;
}
/// <summary>
/// Creates and configures an <see cref="HttpClient"/> after validating the supplied egress request against the policy.
/// </summary>
@@ -42,4 +71,23 @@ public static class EgressHttpClientFactory
string intent,
Action<HttpClient>? configure = null)
=> Create(egressPolicy, new EgressRequest(component, destination, intent), configure);
/// <summary>
/// Creates a configured <see cref="HttpClient"/> using a caller-provided factory after policy validation.
/// </summary>
/// <param name="egressPolicy">The policy used to validate outbound requests.</param>
/// <param name="component">Component initiating the request.</param>
/// <param name="destination">Destination that will be contacted.</param>
/// <param name="intent">Intent label describing why the request is needed.</param>
/// <param name="clientFactory">Factory used to supply a configured client.</param>
/// <param name="configure">Optional configuration hook applied to the newly created client.</param>
/// <returns>An <see cref="HttpClient"/> that has been pre-authorised by the policy.</returns>
public static HttpClient Create(
IEgressPolicy egressPolicy,
string component,
Uri destination,
string intent,
Func<HttpClient> clientFactory,
Action<HttpClient>? configure = null)
=> Create(egressPolicy, new EgressRequest(component, destination, intent), clientFactory, configure);
}

View File

@@ -3,6 +3,7 @@ using System.Globalization;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
namespace StellaOps.AirGap.Policy;
@@ -11,8 +12,9 @@ namespace StellaOps.AirGap.Policy;
/// </summary>
public sealed class EgressPolicy : IEgressPolicy
{
private readonly EgressRule[] _rules;
private readonly EgressPolicyOptions _options;
private readonly IDisposable? _optionsSubscription;
private EgressRule[] _rules = Array.Empty<EgressRule>();
private EgressPolicyOptions _options = new();
/// <summary>
/// Initializes a new instance of the <see cref="EgressPolicy"/> class.
@@ -20,35 +22,56 @@ public sealed class EgressPolicy : IEgressPolicy
/// <param name="options">Options describing how egress should be enforced.</param>
public EgressPolicy(EgressPolicyOptions options)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_rules = options.BuildRuleSet();
ApplyOptions(options ?? throw new ArgumentNullException(nameof(options)));
}
/// <summary>
/// Initializes a new instance of the <see cref="EgressPolicy"/> class with reload support.
/// </summary>
/// <param name="optionsMonitor">Options monitor that supplies updated policy settings.</param>
public EgressPolicy(IOptionsMonitor<EgressPolicyOptions> optionsMonitor)
{
ArgumentNullException.ThrowIfNull(optionsMonitor);
ApplyOptions(optionsMonitor.CurrentValue);
_optionsSubscription = optionsMonitor.OnChange((updated, _) => ApplyOptions(updated));
}
/// <inheritdoc />
public bool IsSealed => Mode == EgressPolicyMode.Sealed;
/// <inheritdoc />
public EgressPolicyMode Mode => _options.Mode;
public EgressPolicyMode Mode => Volatile.Read(ref _options).Mode;
/// <inheritdoc />
public EgressDecision Evaluate(EgressRequest request)
{
if (!HasValidDestination(request))
{
return EgressDecision.Blocked(
"Egress request is missing a valid destination URI.",
BuildInvalidRequestRemediation(request));
}
var options = Volatile.Read(ref _options);
var rules = Volatile.Read(ref _rules);
if (!IsSealed)
{
return EgressDecision.Allowed;
}
if (_options.AllowLoopback && IsLoopback(request.Destination))
if (options.AllowLoopback && IsLoopback(request.Destination))
{
return EgressDecision.Allowed;
}
if (_options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
if (options.AllowPrivateNetworks && IsPrivateNetwork(request.Destination))
{
return EgressDecision.Allowed;
}
foreach (var rule in _rules)
foreach (var rule in rules)
{
if (rule.Allows(request))
{
@@ -56,8 +79,9 @@ public sealed class EgressPolicy : IEgressPolicy
}
}
var reason = $"Destination '{request.Destination.Host}' is not present in the sealed-mode allow list.";
var remediation = BuildRemediation(request);
var destinationLabel = request.Destination?.Host ?? "unknown-host";
var reason = $"Destination '{destinationLabel}' is not present in the sealed-mode allow list.";
var remediation = BuildRemediation(request, rules);
return EgressDecision.Blocked(reason, remediation);
}
@@ -95,14 +119,21 @@ public sealed class EgressPolicy : IEgressPolicy
=> new(
request,
decision.Reason ?? "Egress blocked.",
decision.Remediation ?? BuildRemediation(request),
_options.RemediationDocumentationUrl,
_options.SupportContact);
decision.Remediation ?? BuildRemediation(request, Volatile.Read(ref _rules)),
Volatile.Read(ref _options).RemediationDocumentationUrl,
Volatile.Read(ref _options).SupportContact);
private string BuildRemediation(EgressRequest request)
private string BuildRemediation(EgressRequest request, EgressRule[] rules)
{
var host = request.Destination.Host;
var portSegment = request.Destination.IsDefaultPort ? string.Empty : $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}";
var host = request.Destination?.Host;
if (string.IsNullOrWhiteSpace(host))
{
host = "unknown-host";
}
var portSegment = request.Destination is { IsDefaultPort: false }
? $":{request.Destination.Port.ToString(CultureInfo.InvariantCulture)}"
: string.Empty;
var transport = request.Transport.ToString().ToUpperInvariant();
var builder = new System.Text.StringBuilder();
@@ -113,14 +144,14 @@ public sealed class EgressPolicy : IEgressPolicy
.Append(transport)
.Append(") to the airgap.egressAllowlist configuration.");
if (_rules.Length == 0)
if (rules.Length == 0)
{
builder.Append(" No allow entries are currently configured; sealed mode blocks every external host.");
}
else
{
builder.Append(" Current allow list sample: ");
var limit = Math.Min(_rules.Length, 3);
var limit = Math.Min(rules.Length, 3);
for (var i = 0; i < limit; i++)
{
if (i > 0)
@@ -128,15 +159,15 @@ public sealed class EgressPolicy : IEgressPolicy
builder.Append(", ");
}
builder.Append(_rules[i].HostPattern);
if (_rules[i].Port is int port)
builder.Append(rules[i].HostPattern);
if (rules[i].Port is int port)
{
builder.Append(':')
.Append(port.ToString(CultureInfo.InvariantCulture));
}
}
if (_rules.Length > limit)
if (rules.Length > limit)
{
builder.Append(", ...");
}
@@ -147,6 +178,16 @@ public sealed class EgressPolicy : IEgressPolicy
return builder.ToString();
}
private static string BuildInvalidRequestRemediation(EgressRequest request)
{
var component = string.IsNullOrWhiteSpace(request.Component) ? "unknown-component" : request.Component;
var intent = string.IsNullOrWhiteSpace(request.Intent) ? "unknown-intent" : request.Intent;
return $"Provide an absolute destination URI for component '{component}' (intent: {intent}) before evaluating sealed-mode egress.";
}
private static bool HasValidDestination(EgressRequest request)
=> request.Destination is { IsAbsoluteUri: true };
private static bool IsLoopback(Uri destination)
{
if (string.Equals(destination.Host, "localhost", StringComparison.OrdinalIgnoreCase))
@@ -183,9 +224,20 @@ public sealed class EgressPolicy : IEgressPolicy
if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
{
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal;
var bytes = address.GetAddressBytes();
var isUniqueLocal = bytes.Length > 0 && (bytes[0] & 0xFE) == 0xFC; // fc00::/7
return address.IsIPv6LinkLocal || address.IsIPv6SiteLocal || isUniqueLocal;
}
return false;
}
private void ApplyOptions(EgressPolicyOptions options)
{
ArgumentNullException.ThrowIfNull(options);
var rules = options.BuildRuleSet();
Volatile.Write(ref _rules, rules);
Volatile.Write(ref _options, options);
}
}

View File

@@ -34,8 +34,8 @@ public static class EgressPolicyServiceCollectionExtensions
services.TryAddSingleton<IEgressPolicy>(sp =>
{
var options = sp.GetRequiredService<IOptions<EgressPolicyOptions>>().Value;
return new EgressPolicy(options);
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<EgressPolicyOptions>>();
return new EgressPolicy(optionsMonitor);
});
return services;
@@ -122,6 +122,7 @@ public static class EgressPolicyServiceCollectionExtensions
}
var rules = new List<EgressRule>();
var seenRules = new HashSet<RuleKey>();
foreach (var ruleSection in EnumerateAllowRuleSections(effectiveSection, primarySection, root))
{
var hostPattern = ruleSection["HostPattern"]
@@ -141,7 +142,11 @@ public static class EgressPolicyServiceCollectionExtensions
var description = ruleSection["Description"] ?? ruleSection["Notes"];
description = string.IsNullOrWhiteSpace(description) ? null : description.Trim();
rules.Add(new EgressRule(hostPattern, port, transport, description));
var ruleKey = RuleKey.Create(hostPattern, port, transport);
if (seenRules.Add(ruleKey))
{
rules.Add(new EgressRule(hostPattern, port, transport, description));
}
}
options.SetAllowRules(rules);
@@ -279,4 +284,10 @@ public static class EgressPolicyServiceCollectionExtensions
? parsed
: EgressTransport.Any;
}
private readonly record struct RuleKey(string HostPattern, int? Port, EgressTransport Transport)
{
public static RuleKey Create(string hostPattern, int? port, EgressTransport transport)
=> new(hostPattern.Trim().ToLowerInvariant(), port, transport);
}
}

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0030-M | DONE | Maintainability audit for StellaOps.AirGap.Policy. |
| AUDIT-0030-T | DONE | Test coverage audit for StellaOps.AirGap.Policy. |
| AUDIT-0030-A | TODO | Pending approval for changes. |
| AUDIT-0030-A | DONE | Applied reloadable policy, allowlist de-dup, request guards, and client factory overload. |

View File

@@ -11,13 +11,20 @@ public class TimeStatusController : ControllerBase
private readonly TimeStatusService _statusService;
private readonly TimeAnchorLoader _loader;
private readonly TrustRootProvider _trustRoots;
private readonly TimeProvider _timeProvider;
private readonly ILogger<TimeStatusController> _logger;
public TimeStatusController(TimeStatusService statusService, TimeAnchorLoader loader, TrustRootProvider trustRoots, ILogger<TimeStatusController> logger)
public TimeStatusController(
TimeStatusService statusService,
TimeAnchorLoader loader,
TrustRootProvider trustRoots,
TimeProvider timeProvider,
ILogger<TimeStatusController> logger)
{
_statusService = statusService;
_loader = loader;
_trustRoots = trustRoots;
_timeProvider = timeProvider;
_logger = logger;
}
@@ -29,7 +36,7 @@ public class TimeStatusController : ControllerBase
return BadRequest("tenantId-required");
}
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted);
return Ok(TimeStatusDto.FromStatus(status));
}
@@ -73,7 +80,7 @@ public class TimeStatusController : ControllerBase
await _statusService.SetAnchorAsync(request.TenantId, anchor, budget, HttpContext.RequestAborted);
_logger.LogInformation("Time anchor set for tenant {Tenant} format={Format} digest={Digest} warning={Warning}s breach={Breach}s", request.TenantId, anchor.Format, anchor.TokenDigest, budget.WarningSeconds, budget.BreachSeconds);
var status = await _statusService.GetStatusAsync(request.TenantId, DateTimeOffset.UtcNow, HttpContext.RequestAborted);
var status = await _statusService.GetStatusAsync(request.TenantId, _timeProvider.GetUtcNow(), HttpContext.RequestAborted);
return Ok(TimeStatusDto.FromStatus(status));
}
}

View File

@@ -9,17 +9,19 @@ public sealed class TimeAnchorHealthCheck : IHealthCheck
{
private readonly TimeStatusService _statusService;
private readonly IOptions<AirGapOptions> _options;
private readonly TimeProvider _timeProvider;
public TimeAnchorHealthCheck(TimeStatusService statusService, IOptions<AirGapOptions> options)
public TimeAnchorHealthCheck(TimeStatusService statusService, IOptions<AirGapOptions> options, TimeProvider timeProvider)
{
_statusService = statusService;
_options = options;
_timeProvider = timeProvider;
}
public async Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
{
var opts = _options.Value;
var status = await _statusService.GetStatusAsync(opts.TenantId, DateTimeOffset.UtcNow, cancellationToken);
var status = await _statusService.GetStatusAsync(opts.TenantId, _timeProvider.GetUtcNow(), cancellationToken);
if (status.Anchor == TimeAnchor.Unknown)
{

View File

@@ -1,5 +1,6 @@
using StellaOps.AirGap.Time.Hooks;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Services;
using StellaOps.AirGap.Time.Stores;
@@ -13,7 +14,8 @@ var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<StalenessCalculator>();
builder.Services.AddSingleton<TimeTelemetry>();
builder.Services.AddSingleton<TimeStatusService>();
builder.Services.AddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);
builder.Services.TryAddSingleton<ITimeAnchorStore, InMemoryTimeAnchorStore>();
builder.Services.AddSingleton<TimeVerificationService>();
builder.Services.AddSingleton<TimeAnchorLoader>();
builder.Services.AddSingleton<TimeTokenParser>();

View File

@@ -23,6 +23,8 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
// Ed25519 constants
private const int Ed25519SignatureLength = 64;
private const int Ed25519PublicKeyLength = 32;
private const int MerkleNodeLength = 32;
private const int MerkleIndexLength = 4;
public TimeTokenFormat Format => TimeTokenFormat.Roughtime;
@@ -187,7 +189,7 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
}
// Parse SREP (signed response) for MIDP and RADI
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros);
var srepResult = ParseSignedResponse(srepBytes, out midpointMicros, out radiusMicros, out _, out _, out _);
if (!srepResult.IsValid)
{
return srepResult;
@@ -202,10 +204,16 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
private static TimeAnchorValidationResult ParseSignedResponse(
ReadOnlySpan<byte> data,
out long midpointMicros,
out uint radiusMicros)
out uint radiusMicros,
out ReadOnlySpan<byte> rootBytes,
out ReadOnlySpan<byte> pathBytes,
out uint index)
{
midpointMicros = 0;
radiusMicros = 0;
rootBytes = ReadOnlySpan<byte>.Empty;
pathBytes = ReadOnlySpan<byte>.Empty;
index = 0;
if (data.Length < 8)
{
@@ -232,6 +240,9 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
var hasMidp = false;
var hasRadi = false;
var hasRoot = false;
var hasPath = false;
var hasIndex = false;
for (var i = 0; i < (int)numTags; i++)
{
@@ -273,6 +284,27 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
hasRadi = true;
}
break;
case TagRoot:
if (value.Length == MerkleNodeLength)
{
rootBytes = value;
hasRoot = true;
}
break;
case TagPath:
if (!value.IsEmpty && value.Length % MerkleNodeLength == 0)
{
pathBytes = value;
hasPath = true;
}
break;
case TagIndx:
if (value.Length == MerkleIndexLength)
{
index = BinaryPrimitives.ReadUInt32LittleEndian(value);
hasIndex = true;
}
break;
}
}
@@ -281,6 +313,27 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
return TimeAnchorValidationResult.Failure("roughtime-missing-midpoint");
}
if (!hasRoot)
{
return TimeAnchorValidationResult.Failure("roughtime-missing-root");
}
if (!hasPath)
{
return TimeAnchorValidationResult.Failure("roughtime-missing-path");
}
if (!hasIndex)
{
return TimeAnchorValidationResult.Failure("roughtime-missing-index");
}
var pathValidation = ValidateMerklePathStructure(rootBytes, pathBytes, index);
if (!pathValidation.IsValid)
{
return pathValidation;
}
if (!hasRadi)
{
// RADI is optional, default to 1 second uncertainty
@@ -290,6 +343,31 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
return TimeAnchorValidationResult.Success("roughtime-srep-parsed");
}
private static TimeAnchorValidationResult ValidateMerklePathStructure(ReadOnlySpan<byte> rootBytes, ReadOnlySpan<byte> pathBytes, uint index)
{
if (rootBytes.Length != MerkleNodeLength)
{
return TimeAnchorValidationResult.Failure("roughtime-invalid-root-length");
}
if (pathBytes.IsEmpty || pathBytes.Length % MerkleNodeLength != 0)
{
return TimeAnchorValidationResult.Failure("roughtime-invalid-path-length");
}
var depth = pathBytes.Length / MerkleNodeLength;
if (depth <= 31)
{
var maxIndex = 1u << depth;
if (index >= maxIndex)
{
return TimeAnchorValidationResult.Failure("roughtime-invalid-index");
}
}
return TimeAnchorValidationResult.Success("roughtime-merkle-structure-valid");
}
private static bool VerifyEd25519Signature(ReadOnlySpan<byte> message, ReadOnlySpan<byte> signature, byte[] publicKey)
{
try
@@ -301,8 +379,6 @@ public sealed class RoughtimeVerifier : ITimeTokenVerifier
prefixBytes.CopyTo(signedData, 0);
message.CopyTo(signedData.AsSpan(prefixBytes.Length));
using var ed25519 = ECDiffieHellman.Create(ECCurve.CreateFromFriendlyName("curve25519"));
// Use .NET's Ed25519 verification
// Note: .NET 10 supports Ed25519 natively via ECDsa with curve Ed25519
return Ed25519.Verify(publicKey, signedData, signature.ToArray());

View File

@@ -15,15 +15,17 @@ public sealed record StartupValidationResult(bool IsValid, string Reason, TimeSt
public sealed class SealedStartupValidator
{
private readonly TimeStatusService _statusService;
private readonly TimeProvider _timeProvider;
public SealedStartupValidator(TimeStatusService statusService)
public SealedStartupValidator(TimeStatusService statusService, TimeProvider timeProvider)
{
_statusService = statusService;
_timeProvider = timeProvider;
}
public async Task<StartupValidationResult> ValidateAsync(string tenantId, StalenessBudget budget, CancellationToken cancellationToken)
{
var status = await _statusService.GetStatusAsync(tenantId, DateTimeOffset.UtcNow, cancellationToken);
var status = await _statusService.GetStatusAsync(tenantId, _timeProvider.GetUtcNow(), cancellationToken);
if (status.Anchor == TimeAnchor.Unknown)
{

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Options;
using System.Threading;
using StellaOps.AirGap.Time.Models;
using StellaOps.AirGap.Time.Stores;
@@ -12,14 +13,15 @@ public sealed class TimeStatusService
private readonly ITimeAnchorStore _store;
private readonly StalenessCalculator _calculator;
private readonly TimeTelemetry _telemetry;
private readonly IReadOnlyDictionary<string, StalenessBudget> _contentBudgets;
private IReadOnlyDictionary<string, StalenessBudget> _contentBudgets;
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator, TimeTelemetry telemetry, IOptions<AirGapOptions> options)
public TimeStatusService(ITimeAnchorStore store, StalenessCalculator calculator, TimeTelemetry telemetry, IOptionsMonitor<AirGapOptions> options)
{
_store = store;
_calculator = calculator;
_telemetry = telemetry;
_contentBudgets = BuildContentBudgets(options.Value);
_contentBudgets = BuildContentBudgets(options.CurrentValue);
options.OnChange(opts => Interlocked.Exchange(ref _contentBudgets, BuildContentBudgets(opts)));
}
public async Task SetAnchorAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken = default)

View File

@@ -7,25 +7,40 @@ namespace StellaOps.AirGap.Time.Services;
public sealed class TimeTelemetry
{
private static readonly Meter Meter = new("StellaOps.AirGap.Time", "1.0.0");
private const int MaxEntries = 1024;
private static readonly ConcurrentDictionary<string, Snapshot> _latest = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, Snapshot> _latest = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<string> _evictionQueue = new();
private static readonly ObservableGauge<long> AnchorAgeGauge = Meter.CreateObservableGauge(
"airgap_time_anchor_age_seconds",
() => _latest.Select(kvp => new Measurement<long>(kvp.Value.AgeSeconds, new KeyValuePair<string, object?>("tenant", kvp.Key))));
private readonly ObservableGauge<long> _anchorAgeGauge;
private static readonly Counter<long> StatusCounter = Meter.CreateCounter<long>("airgap_time_anchor_status_total");
private static readonly Counter<long> WarningCounter = Meter.CreateCounter<long>("airgap_time_anchor_warning_total");
private static readonly Counter<long> BreachCounter = Meter.CreateCounter<long>("airgap_time_anchor_breach_total");
public TimeTelemetry()
{
_anchorAgeGauge = Meter.CreateObservableGauge(
"airgap_time_anchor_age_seconds",
() => _latest.Select(kvp => new Measurement<long>(kvp.Value.AgeSeconds, new KeyValuePair<string, object?>("tenant", kvp.Key))));
}
public void Record(string tenantId, Models.TimeStatus status)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
return;
}
var normalizedTenant = tenantId.Trim();
var snapshot = new Snapshot(status.Staleness.AgeSeconds, status.Staleness.IsWarning, status.Staleness.IsBreach);
_latest[tenantId] = snapshot;
_latest[normalizedTenant] = snapshot;
_evictionQueue.Enqueue(normalizedTenant);
TrimCache();
var tags = new TagList
{
{ "tenant", tenantId },
{ "tenant", normalizedTenant },
{ "is_warning", status.Staleness.IsWarning },
{ "is_breach", status.Staleness.IsBreach }
};
@@ -45,7 +60,21 @@ public sealed class TimeTelemetry
public Snapshot? GetLatest(string tenantId)
{
return _latest.TryGetValue(tenantId, out var snap) ? snap : null;
if (string.IsNullOrWhiteSpace(tenantId))
{
return null;
}
var normalizedTenant = tenantId.Trim();
return _latest.TryGetValue(normalizedTenant, out var snap) ? snap : null;
}
private void TrimCache()
{
while (_latest.Count > MaxEntries && _evictionQueue.TryDequeue(out var candidate))
{
_latest.TryRemove(candidate, out _);
}
}
public sealed record Snapshot(long AgeSeconds, bool IsWarning, bool IsBreach);

View File

@@ -26,12 +26,21 @@ public sealed class TrustRootProvider
var doc = JsonDocument.Parse(stream);
var roots = new List<TimeTrustRoot>();
if (doc.RootElement.TryGetProperty("roughtime", out var roughtimeArr))
if (doc.RootElement.TryGetProperty("roughtime", out var roughtimeArr) && roughtimeArr.ValueKind == JsonValueKind.Array)
{
foreach (var item in roughtimeArr.EnumerateArray())
{
var name = item.GetProperty("name").GetString() ?? "unknown-roughtime";
var pkB64 = item.GetProperty("publicKeyBase64").GetString() ?? string.Empty;
var name = item.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String
? nameProp.GetString() ?? "unknown-roughtime"
: "unknown-roughtime";
if (!item.TryGetProperty("publicKeyBase64", out var pkProp) || pkProp.ValueKind != JsonValueKind.String)
{
_logger.LogWarning("Roughtime trust root {Name} missing publicKeyBase64 field.", name);
continue;
}
var pkB64 = pkProp.GetString() ?? string.Empty;
try
{
var pk = Convert.FromBase64String(pkB64);
@@ -44,12 +53,21 @@ public sealed class TrustRootProvider
}
}
if (doc.RootElement.TryGetProperty("rfc3161", out var rfcArr))
if (doc.RootElement.TryGetProperty("rfc3161", out var rfcArr) && rfcArr.ValueKind == JsonValueKind.Array)
{
foreach (var item in rfcArr.EnumerateArray())
{
var name = item.GetProperty("name").GetString() ?? "unknown-rfc3161";
var certPem = item.GetProperty("certificatePem").GetString() ?? string.Empty;
var name = item.TryGetProperty("name", out var nameProp) && nameProp.ValueKind == JsonValueKind.String
? nameProp.GetString() ?? "unknown-rfc3161"
: "unknown-rfc3161";
if (!item.TryGetProperty("certificatePem", out var certProp) || certProp.ValueKind != JsonValueKind.String)
{
_logger.LogWarning("RFC3161 trust root {Name} missing certificatePem field.", name);
continue;
}
var certPem = certProp.GetString() ?? string.Empty;
var normalized = certPem.Replace("-----BEGIN CERTIFICATE-----", string.Empty)
.Replace("-----END CERTIFICATE-----", string.Empty)
.Replace("\n", string.Empty)

View File

@@ -1,22 +1,27 @@
using System.Collections.Concurrent;
using StellaOps.AirGap.Time.Models;
namespace StellaOps.AirGap.Time.Stores;
public sealed class InMemoryTimeAnchorStore : ITimeAnchorStore
{
private readonly Dictionary<string, (TimeAnchor Anchor, StalenessBudget Budget)> _anchors = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, (TimeAnchor Anchor, StalenessBudget Budget)> _anchors = new(StringComparer.OrdinalIgnoreCase);
public Task SetAsync(string tenantId, TimeAnchor anchor, StalenessBudget budget, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
_anchors[tenantId] = (anchor, budget);
if (!string.IsNullOrWhiteSpace(tenantId))
{
_anchors[tenantId.Trim()] = (anchor, budget);
}
return Task.CompletedTask;
}
public Task<(TimeAnchor Anchor, StalenessBudget Budget)> GetAsync(string tenantId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (_anchors.TryGetValue(tenantId, out var value))
if (!string.IsNullOrWhiteSpace(tenantId) &&
_anchors.TryGetValue(tenantId.Trim(), out var value))
{
return Task.FromResult(value);
}

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0034-M | DONE | Maintainability audit for StellaOps.AirGap.Time. |
| AUDIT-0034-T | DONE | Test coverage audit for StellaOps.AirGap.Time. |
| AUDIT-0034-A | TODO | Pending approval for changes. |
| AUDIT-0034-A | DONE | Applied time provider, options reload, and trust-root/roughtime hardening. |

View File

@@ -1,4 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.Infrastructure.Postgres.Options;
namespace StellaOps.AirGap.Persistence.EfCore.Context;
@@ -8,14 +11,25 @@ namespace StellaOps.AirGap.Persistence.EfCore.Context;
/// </summary>
public class AirGapDbContext : DbContext
{
private readonly string _schemaName;
public AirGapDbContext(DbContextOptions<AirGapDbContext> options)
: this(options, null)
{
}
public AirGapDbContext(DbContextOptions<AirGapDbContext> options, IOptions<PostgresOptions>? postgresOptions)
: base(options)
{
var schema = postgresOptions?.Value.SchemaName;
_schemaName = string.IsNullOrWhiteSpace(schema)
? AirGapDataSource.DefaultSchemaName
: schema;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("airgap");
modelBuilder.HasDefaultSchema(_schemaName);
base.OnModelCreating(modelBuilder);
}
}

View File

@@ -1,10 +1,13 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Controller.Stores;
using StellaOps.AirGap.Importer.Versioning;
using StellaOps.AirGap.Persistence.Postgres;
using StellaOps.AirGap.Persistence.Postgres.Repositories;
using StellaOps.Infrastructure.Postgres.Options;
using Npgsql;
namespace StellaOps.AirGap.Persistence.Extensions;
@@ -23,6 +26,7 @@ public static class AirGapPersistenceExtensions
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
services.AddSingleton<AirGapDataSource>();
services.AddHostedService(sp => CreateMigrationHost(sp));
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
@@ -38,9 +42,46 @@ public static class AirGapPersistenceExtensions
{
services.Configure(configureOptions);
services.AddSingleton<AirGapDataSource>();
services.AddHostedService(sp => CreateMigrationHost(sp));
services.AddScoped<IAirGapStateStore, PostgresAirGapStateStore>();
services.AddScoped<IBundleVersionStore, PostgresBundleVersionStore>();
return services;
}
private static IHostedService CreateMigrationHost(IServiceProvider serviceProvider)
{
var options = serviceProvider.GetRequiredService<Microsoft.Extensions.Options.IOptions<PostgresOptions>>().Value;
var schemaName = string.IsNullOrWhiteSpace(options.SchemaName)
? AirGapDataSource.DefaultSchemaName
: options.SchemaName!;
var connectionString = BuildMigrationConnectionString(options, schemaName);
var logger = serviceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("Migration.AirGap.Persistence");
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
return new AirGapStartupMigrationHost(
connectionString,
schemaName,
"AirGap.Persistence",
typeof(AirGapDataSource).Assembly,
logger,
lifetime);
}
private static string BuildMigrationConnectionString(PostgresOptions options, string schemaName)
{
var builder = new NpgsqlConnectionStringBuilder(options.ConnectionString)
{
CommandTimeout = options.CommandTimeoutSeconds
};
if (!string.IsNullOrWhiteSpace(schemaName))
{
builder.SearchPath = $"{schemaName}, public";
}
return builder.ConnectionString;
}
}

View File

@@ -0,0 +1,21 @@
using System.Reflection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StellaOps.Infrastructure.Postgres.Migrations;
namespace StellaOps.AirGap.Persistence.Postgres;
internal sealed class AirGapStartupMigrationHost : StartupMigrationHost
{
public AirGapStartupMigrationHost(
string connectionString,
string schemaName,
string moduleName,
Assembly migrationsAssembly,
ILogger logger,
IHostApplicationLifetime lifetime,
StartupMigrationOptions? options = null)
: base(connectionString, schemaName, moduleName, migrationsAssembly, logger, lifetime, options)
{
}
}

View File

@@ -26,25 +26,47 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", cancellationToken).ConfigureAwait(false);
const string sql = """
var tenantKey = NormalizeTenantId(tenantId);
var stateTable = GetQualifiedTableName("state");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", cancellationToken).ConfigureAwait(false);
var sql = $$"""
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
FROM state
WHERE LOWER(tenant_id) = LOWER(@tenant_id);
FROM {{stateTable}}
WHERE tenant_id = @tenant_id;
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "tenant_id", tenantId);
AddParameter(command, "tenant_id", tenantKey);
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
await using (var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false))
{
// Return default state for tenant if not found
return new AirGapState { TenantId = tenantId };
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return Map(reader);
}
}
return Map(reader);
// Fallback for legacy rows stored without normalization.
await using var fallbackCommand = CreateCommand($$"""
SELECT id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
FROM {{stateTable}}
WHERE LOWER(tenant_id) = LOWER(@tenant_id)
ORDER BY updated_at DESC, id DESC
LIMIT 1;
""", connection);
AddParameter(fallbackCommand, "tenant_id", tenantId);
await using var fallbackReader = await fallbackCommand.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
if (await fallbackReader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return Map(fallbackReader);
}
// Return default state for tenant if not found
return new AirGapState { TenantId = tenantId };
}
public async Task SetAsync(AirGapState state, CancellationToken cancellationToken = default)
@@ -52,9 +74,12 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
ArgumentNullException.ThrowIfNull(state);
await EnsureTableAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", cancellationToken).ConfigureAwait(false);
const string sql = """
INSERT INTO state (
var tenantKey = NormalizeTenantId(state.TenantId);
var stateTable = GetQualifiedTableName("state");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", cancellationToken).ConfigureAwait(false);
var sql = $$"""
INSERT INTO {{stateTable}} (
id, tenant_id, sealed, policy_hash, time_anchor, last_transition_at,
staleness_budget, drift_baseline_seconds, content_budgets
)
@@ -76,7 +101,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
await using var command = CreateCommand(sql, connection);
AddParameter(command, "id", state.Id);
AddParameter(command, "tenant_id", state.TenantId);
AddParameter(command, "tenant_id", tenantKey);
AddParameter(command, "sealed", state.Sealed);
AddParameter(command, "policy_hash", (object?)state.PolicyHash ?? DBNull.Value);
AddJsonbParameter(command, "time_anchor", SerializeTimeAnchor(state.TimeAnchor));
@@ -88,7 +113,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static AirGapState Map(NpgsqlDataReader reader)
private AirGapState Map(NpgsqlDataReader reader)
{
var id = reader.GetString(0);
var tenantId = reader.GetString(1);
@@ -133,7 +158,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return JsonSerializer.Serialize(obj);
}
private static TimeAnchor DeserializeTimeAnchor(string json)
private TimeAnchor DeserializeTimeAnchor(string json)
{
try
{
@@ -152,8 +177,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return new TimeAnchor(anchorTime, source, format, signatureFingerprint, tokenDigest);
}
catch
catch (Exception ex)
{
Logger.LogWarning(ex, "AirGap state: Failed to parse time anchor JSON; using default.");
return TimeAnchor.Unknown;
}
}
@@ -168,7 +194,7 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return JsonSerializer.Serialize(obj);
}
private static StalenessBudget DeserializeStalenessBudget(string json)
private StalenessBudget DeserializeStalenessBudget(string json)
{
try
{
@@ -180,8 +206,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return new StalenessBudget(warningSeconds, breachSeconds);
}
catch
catch (Exception ex)
{
Logger.LogWarning(ex, "AirGap state: Failed to parse staleness budget JSON; using default.");
return StalenessBudget.Default;
}
}
@@ -193,14 +220,20 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return "{}";
}
var dict = budgets.ToDictionary(
kv => kv.Key,
kv => new { warningSeconds = kv.Value.WarningSeconds, breachSeconds = kv.Value.BreachSeconds });
var dict = new SortedDictionary<string, object>(StringComparer.Ordinal);
foreach (var kv in budgets.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
dict[kv.Key] = new
{
warningSeconds = kv.Value.WarningSeconds,
breachSeconds = kv.Value.BreachSeconds
};
}
return JsonSerializer.Serialize(dict);
}
private static IReadOnlyDictionary<string, StalenessBudget> DeserializeContentBudgets(string? json)
private IReadOnlyDictionary<string, StalenessBudget> DeserializeContentBudgets(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
@@ -221,8 +254,9 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
return result;
}
catch
catch (Exception ex)
{
Logger.LogWarning(ex, "AirGap state: Failed to parse content budgets JSON; using defaults.");
return new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
}
}
@@ -245,29 +279,12 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
}
await using var connection = await DataSource.OpenSystemConnectionAsync(cancellationToken).ConfigureAwait(false);
var schemaName = DataSource.SchemaName ?? "public";
var quotedSchema = QuoteIdentifier(schemaName);
var sql = $$"""
CREATE SCHEMA IF NOT EXISTS {{quotedSchema}};
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.state (
id TEXT NOT NULL,
tenant_id TEXT NOT NULL PRIMARY KEY,
sealed BOOLEAN NOT NULL DEFAULT FALSE,
policy_hash TEXT,
time_anchor JSONB NOT NULL DEFAULT '{}'::jsonb,
last_transition_at TIMESTAMPTZ NOT NULL DEFAULT '0001-01-01T00:00:00Z',
staleness_budget JSONB NOT NULL DEFAULT '{"warningSeconds":3600,"breachSeconds":7200}'::jsonb,
drift_baseline_seconds BIGINT NOT NULL DEFAULT 0,
content_budgets JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_airgap_state_tenant ON {{quotedSchema}}.state(tenant_id);
CREATE INDEX IF NOT EXISTS idx_airgap_state_sealed ON {{quotedSchema}}.state(sealed) WHERE sealed = TRUE;
""";
await using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
var schemaName = GetSchemaName();
if (!await TableExistsAsync(connection, schemaName, "state", cancellationToken).ConfigureAwait(false))
{
throw new InvalidOperationException(
$"AirGap state table missing in schema '{schemaName}'. Run AirGap migrations before using the store.");
}
_initialized = true;
}
finally
@@ -276,6 +293,46 @@ public sealed class PostgresAirGapStateStore : RepositoryBase<AirGapDataSource>,
}
}
private async Task<bool> TableExistsAsync(
NpgsqlConnection connection,
string schemaName,
string tableName,
CancellationToken cancellationToken)
{
const string sql = """
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = @schema AND table_name = @table
);
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "schema", schemaName);
AddParameter(command, "table", tableName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private string GetQualifiedTableName(string tableName)
{
var schema = GetSchemaName();
return $"{QuoteIdentifier(schema)}.{QuoteIdentifier(tableName)}";
}
private string GetSchemaName()
{
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
{
return DataSource.SchemaName!;
}
return AirGapDataSource.DefaultSchemaName;
}
private static string NormalizeTenantId(string tenantId) => tenantId.Trim().ToLowerInvariant();
private static string QuoteIdentifier(string identifier)
{
var escaped = identifier.Replace("\"", "\"\"", StringComparison.Ordinal);

View File

@@ -31,11 +31,12 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
var tenantKey = NormalizeKey(tenantId);
var bundleTypeKey = NormalizeKey(bundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
const string sql = """
var versionTable = GetQualifiedTableName("bundle_versions");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", ct).ConfigureAwait(false);
var sql = $$"""
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
FROM bundle_versions
FROM {{versionTable}}
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type;
""";
@@ -55,11 +56,13 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
var tenantKey = NormalizeKey(record.TenantId);
var bundleTypeKey = NormalizeKey(record.BundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "writer", ct).ConfigureAwait(false);
var versionTable = GetQualifiedTableName("bundle_versions");
var historyTable = GetQualifiedTableName("bundle_version_history");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "writer", ct).ConfigureAwait(false);
await using var tx = await connection.BeginTransactionAsync(ct).ConfigureAwait(false);
const string closeHistorySql = """
UPDATE bundle_version_history
var closeHistorySql = $$"""
UPDATE {{historyTable}}
SET deactivated_at = @activated_at
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type AND deactivated_at IS NULL;
""";
@@ -73,8 +76,8 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
await closeCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
const string historySql = """
INSERT INTO bundle_version_history (
var historySql = $$"""
INSERT INTO {{historyTable}} (
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, deactivated_at, was_force_activated, force_activate_reason
)
@@ -102,8 +105,8 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
await historyCmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
const string upsertSql = """
INSERT INTO bundle_versions (
var upsertSql = $$"""
INSERT INTO {{versionTable}} (
tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
)
@@ -165,13 +168,14 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
var tenantKey = NormalizeKey(tenantId);
var bundleTypeKey = NormalizeKey(bundleType);
await using var connection = await DataSource.OpenConnectionAsync("public", "reader", ct).ConfigureAwait(false);
const string sql = """
var historyTable = GetQualifiedTableName("bundle_version_history");
await using var connection = await DataSource.OpenConnectionAsync(tenantKey, "reader", ct).ConfigureAwait(false);
var sql = $$"""
SELECT tenant_id, bundle_type, version_string, major, minor, patch, prerelease,
bundle_created_at, bundle_digest, activated_at, was_force_activated, force_activate_reason
FROM bundle_version_history
FROM {{historyTable}}
WHERE tenant_id = @tenant_id AND bundle_type = @bundle_type
ORDER BY activated_at DESC
ORDER BY activated_at DESC, id DESC
LIMIT @limit;
""";
@@ -236,56 +240,15 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
}
await using var connection = await DataSource.OpenSystemConnectionAsync(ct).ConfigureAwait(false);
var schemaName = DataSource.SchemaName ?? "public";
var quotedSchema = QuoteIdentifier(schemaName);
var sql = $$"""
CREATE SCHEMA IF NOT EXISTS {{quotedSchema}};
var schemaName = GetSchemaName();
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.bundle_versions (
tenant_id TEXT NOT NULL,
bundle_type TEXT NOT NULL,
version_string TEXT NOT NULL,
major INTEGER NOT NULL,
minor INTEGER NOT NULL,
patch INTEGER NOT NULL,
prerelease TEXT,
bundle_created_at TIMESTAMPTZ NOT NULL,
bundle_digest TEXT NOT NULL,
activated_at TIMESTAMPTZ NOT NULL,
was_force_activated BOOLEAN NOT NULL DEFAULT FALSE,
force_activate_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, bundle_type)
);
if (!await TableExistsAsync(connection, schemaName, "bundle_versions", ct).ConfigureAwait(false) ||
!await TableExistsAsync(connection, schemaName, "bundle_version_history", ct).ConfigureAwait(false))
{
throw new InvalidOperationException(
$"AirGap bundle version tables missing in schema '{schemaName}'. Run AirGap migrations before using the store.");
}
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_versions_tenant
ON {{quotedSchema}}.bundle_versions(tenant_id);
CREATE TABLE IF NOT EXISTS {{quotedSchema}}.bundle_version_history (
id BIGSERIAL PRIMARY KEY,
tenant_id TEXT NOT NULL,
bundle_type TEXT NOT NULL,
version_string TEXT NOT NULL,
major INTEGER NOT NULL,
minor INTEGER NOT NULL,
patch INTEGER NOT NULL,
prerelease TEXT,
bundle_created_at TIMESTAMPTZ NOT NULL,
bundle_digest TEXT NOT NULL,
activated_at TIMESTAMPTZ NOT NULL,
deactivated_at TIMESTAMPTZ,
was_force_activated BOOLEAN NOT NULL DEFAULT FALSE,
force_activate_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_airgap_bundle_version_history_tenant
ON {{quotedSchema}}.bundle_version_history(tenant_id, bundle_type, activated_at DESC);
""";
await using var command = CreateCommand(sql, connection);
await command.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
_initialized = true;
}
finally
@@ -294,6 +257,44 @@ public sealed class PostgresBundleVersionStore : RepositoryBase<AirGapDataSource
}
}
private async Task<bool> TableExistsAsync(
NpgsqlConnection connection,
string schemaName,
string tableName,
CancellationToken cancellationToken)
{
const string sql = """
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = @schema AND table_name = @table
);
""";
await using var command = CreateCommand(sql, connection);
AddParameter(command, "schema", schemaName);
AddParameter(command, "table", tableName);
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return result is true;
}
private string GetQualifiedTableName(string tableName)
{
var schema = GetSchemaName();
return $"{QuoteIdentifier(schema)}.{QuoteIdentifier(tableName)}";
}
private string GetSchemaName()
{
if (!string.IsNullOrWhiteSpace(DataSource.SchemaName))
{
return DataSource.SchemaName!;
}
return AirGapDataSource.DefaultSchemaName;
}
private static string NormalizeKey(string value) => value.Trim().ToLowerInvariant();
private static string QuoteIdentifier(string identifier)

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0028-M | DONE | Maintainability audit for StellaOps.AirGap.Persistence. |
| AUDIT-0028-T | DONE | Test coverage audit for StellaOps.AirGap.Persistence. |
| AUDIT-0028-A | TODO | Pending approval for changes. |
| AUDIT-0028-A | DONE | Applied schema + determinism fixes and migration host wiring. |

View File

@@ -11,7 +11,7 @@ public class SealedStartupValidatorTests
[Fact]
public async Task FailsWhenAnchorMissing()
{
var validator = Build(out var statusService);
var validator = Build(out var statusService, DateTimeOffset.UnixEpoch);
var result = await validator.ValidateAsync("t1", StalenessBudget.Default, default);
Assert.False(result.IsValid);
Assert.Equal("time-anchor-missing", result.Reason);
@@ -21,10 +21,10 @@ public class SealedStartupValidatorTests
[Fact]
public async Task FailsWhenBreach()
{
var validator = Build(out var statusService);
var now = DateTimeOffset.UnixEpoch.AddSeconds(25);
var validator = Build(out var statusService, now);
var anchor = new TimeAnchor(DateTimeOffset.UnixEpoch, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var now = DateTimeOffset.UnixEpoch.AddSeconds(25);
var status = await statusService.GetStatusAsync("t1", now);
var result = status.Staleness.IsBreach;
Assert.True(result);
@@ -37,8 +37,8 @@ public class SealedStartupValidatorTests
[Fact]
public async Task SucceedsWhenFresh()
{
var validator = Build(out var statusService);
var now = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UnixEpoch.AddSeconds(5);
var validator = Build(out var statusService, now);
var anchor = new TimeAnchor(now, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var validation = await validator.ValidateAsync("t1", new StalenessBudget(10, 20), default);
@@ -49,8 +49,9 @@ public class SealedStartupValidatorTests
[Fact]
public async Task FailsOnBudgetMismatch()
{
var validator = Build(out var statusService);
var anchor = new TimeAnchor(DateTimeOffset.UtcNow, "src", "fmt", "fp", "digest");
var now = DateTimeOffset.UnixEpoch.AddSeconds(5);
var validator = Build(out var statusService, now);
var anchor = new TimeAnchor(now, "src", "fmt", "fp", "digest");
await statusService.SetAnchorAsync("t1", anchor, new StalenessBudget(10, 20));
var validation = await validator.ValidateAsync("t1", new StalenessBudget(5, 15), default);
@@ -59,10 +60,19 @@ public class SealedStartupValidatorTests
Assert.Equal("time-anchor-budget-mismatch", validation.Reason);
}
private static SealedStartupValidator Build(out TimeStatusService statusService)
private static SealedStartupValidator Build(out TimeStatusService statusService, DateTimeOffset now)
{
var store = new InMemoryTimeAnchorStore();
statusService = new TimeStatusService(store, new StalenessCalculator(), new TimeTelemetry(), Microsoft.Extensions.Options.Options.Create(new AirGapOptions()));
return new SealedStartupValidator(statusService);
statusService = new TimeStatusService(store, new StalenessCalculator(), new TimeTelemetry(), new TestOptionsMonitor<AirGapOptions>(new AirGapOptions()));
return new SealedStartupValidator(statusService, new FixedTimeProvider(now));
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset _now;
public FixedTimeProvider(DateTimeOffset now) => _now = now;
public override DateTimeOffset GetUtcNow() => _now;
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.Extensions.Options;
namespace StellaOps.AirGap.Time.Tests;
internal sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
{
private T _value;
public TestOptionsMonitor(T value)
{
_value = value;
}
public T CurrentValue => _value;
public T Get(string? name) => _value;
public IDisposable OnChange(Action<T, string> listener) => new NoopDisposable();
public void SetValue(T value) => _value = value;
private sealed class NoopDisposable : IDisposable
{
public void Dispose()
{
}
}
}

View File

@@ -31,7 +31,7 @@ public class TimeAnchorPolicyServiceTests
Staleness = new StalenessOptions { WarningSeconds = 3600, BreachSeconds = 7200 },
ContentBudgets = new Dictionary<string, StalenessOptions>()
};
_statusService = new TimeStatusService(_store, _calculator, _telemetry, Options.Create(_airGapOptions));
_statusService = new TimeStatusService(_store, _calculator, _telemetry, new TestOptionsMonitor<AirGapOptions>(_airGapOptions));
}
private TimeAnchorPolicyService CreateService(TimeAnchorPolicyOptions? options = null)

View File

@@ -42,7 +42,7 @@ public class TimeStatusServiceTests
private static TimeStatusService Build(out TimeTelemetry telemetry)
{
telemetry = new TimeTelemetry();
var options = Microsoft.Extensions.Options.Options.Create(new AirGapOptions());
var options = new TestOptionsMonitor<AirGapOptions>(new AirGapOptions());
return new TimeStatusService(new InMemoryTimeAnchorStore(), new StalenessCalculator(), telemetry, options);
}
}