Implement VEX document verification system with issuer management and signature verification

- Added IIssuerDirectory interface for managing VEX document issuers, including methods for registration, revocation, and trust validation.
- Created InMemoryIssuerDirectory class as an in-memory implementation of IIssuerDirectory for testing and single-instance deployments.
- Introduced ISignatureVerifier interface for verifying signatures on VEX documents, with support for multiple signature formats.
- Developed SignatureVerifier class as the default implementation of ISignatureVerifier, allowing extensibility for different signature formats.
- Implemented handlers for DSSE and JWS signature formats, including methods for verification and signature extraction.
- Defined various records and enums for issuer and signature metadata, enhancing the structure and clarity of the verification process.
This commit is contained in:
StellaOps Bot
2025-12-06 13:41:22 +02:00
parent 2141196496
commit 5e514532df
112 changed files with 24861 additions and 211 deletions

View File

@@ -0,0 +1,421 @@
using Microsoft.Extensions.Logging;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Notification types for air-gap events.
/// </summary>
public enum AirGapNotificationType
{
/// <summary>Staleness warning threshold crossed.</summary>
StalenessWarning,
/// <summary>Staleness breach occurred.</summary>
StalenessBreach,
/// <summary>Staleness recovered.</summary>
StalenessRecovered,
/// <summary>Bundle import started.</summary>
BundleImportStarted,
/// <summary>Bundle import completed.</summary>
BundleImportCompleted,
/// <summary>Bundle import failed.</summary>
BundleImportFailed,
/// <summary>Environment sealed.</summary>
EnvironmentSealed,
/// <summary>Environment unsealed.</summary>
EnvironmentUnsealed,
/// <summary>Time anchor missing.</summary>
TimeAnchorMissing,
/// <summary>Policy pack updated.</summary>
PolicyPackUpdated
}
/// <summary>
/// Notification severity levels.
/// </summary>
public enum NotificationSeverity
{
Info,
Warning,
Error,
Critical
}
/// <summary>
/// Represents a notification to be delivered.
/// </summary>
public sealed record AirGapNotification(
string NotificationId,
string TenantId,
AirGapNotificationType Type,
NotificationSeverity Severity,
string Title,
string Message,
DateTimeOffset OccurredAt,
IDictionary<string, object?>? Metadata = null);
/// <summary>
/// Interface for notification delivery channels.
/// </summary>
public interface IAirGapNotificationChannel
{
/// <summary>
/// Gets the name of this notification channel.
/// </summary>
string ChannelName { get; }
/// <summary>
/// Delivers a notification through this channel.
/// </summary>
Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for managing air-gap notifications.
/// </summary>
public interface IAirGapNotificationService
{
/// <summary>
/// Sends a notification through all configured channels.
/// </summary>
Task SendAsync(AirGapNotification notification, CancellationToken cancellationToken = default);
/// <summary>
/// Sends a staleness-related notification.
/// </summary>
Task NotifyStalenessEventAsync(
string tenantId,
StalenessEventType eventType,
int ageSeconds,
int thresholdSeconds,
CancellationToken cancellationToken = default);
/// <summary>
/// Sends a bundle import notification.
/// </summary>
Task NotifyBundleImportAsync(
string tenantId,
string bundleId,
bool success,
string? error = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Sends a sealed-mode state change notification.
/// </summary>
Task NotifySealedStateChangeAsync(
string tenantId,
bool isSealed,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of air-gap notification service.
/// </summary>
internal sealed class AirGapNotificationService : IAirGapNotificationService, IStalenessEventSink
{
private readonly IEnumerable<IAirGapNotificationChannel> _channels;
private readonly TimeProvider _timeProvider;
private readonly ILogger<AirGapNotificationService> _logger;
public AirGapNotificationService(
IEnumerable<IAirGapNotificationChannel> channels,
TimeProvider timeProvider,
ILogger<AirGapNotificationService> logger)
{
_channels = channels ?? [];
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SendAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(notification);
_logger.LogInformation(
"Sending air-gap notification {NotificationId}: {Type} for tenant {TenantId}",
notification.NotificationId, notification.Type, notification.TenantId);
var deliveryTasks = _channels.Select(channel =>
DeliverToChannelAsync(channel, notification, cancellationToken));
await Task.WhenAll(deliveryTasks).ConfigureAwait(false);
}
private async Task DeliverToChannelAsync(
IAirGapNotificationChannel channel,
AirGapNotification notification,
CancellationToken cancellationToken)
{
try
{
var delivered = await channel.DeliverAsync(notification, cancellationToken).ConfigureAwait(false);
if (delivered)
{
_logger.LogDebug(
"Notification {NotificationId} delivered via {Channel}",
notification.NotificationId, channel.ChannelName);
}
else
{
_logger.LogWarning(
"Notification {NotificationId} delivery to {Channel} returned false",
notification.NotificationId, channel.ChannelName);
}
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to deliver notification {NotificationId} via {Channel}",
notification.NotificationId, channel.ChannelName);
}
}
public async Task NotifyStalenessEventAsync(
string tenantId,
StalenessEventType eventType,
int ageSeconds,
int thresholdSeconds,
CancellationToken cancellationToken = default)
{
var (notificationType, severity, title, message) = eventType switch
{
StalenessEventType.Warning => (
AirGapNotificationType.StalenessWarning,
NotificationSeverity.Warning,
"Staleness Warning",
$"Time anchor age ({ageSeconds}s) approaching breach threshold ({thresholdSeconds}s)"),
StalenessEventType.Breach => (
AirGapNotificationType.StalenessBreach,
NotificationSeverity.Critical,
"Staleness Breach",
$"Time anchor staleness breached: age {ageSeconds}s exceeds threshold {thresholdSeconds}s"),
StalenessEventType.Recovered => (
AirGapNotificationType.StalenessRecovered,
NotificationSeverity.Info,
"Staleness Recovered",
"Time anchor has been refreshed, staleness recovered"),
StalenessEventType.AnchorMissing => (
AirGapNotificationType.TimeAnchorMissing,
NotificationSeverity.Error,
"Time Anchor Missing",
"Time anchor not configured in sealed mode"),
_ => (
AirGapNotificationType.StalenessWarning,
NotificationSeverity.Info,
"Staleness Event",
$"Staleness event: {eventType}")
};
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: severity,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["age_seconds"] = ageSeconds,
["threshold_seconds"] = thresholdSeconds,
["event_type"] = eventType.ToString()
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
public async Task NotifyBundleImportAsync(
string tenantId,
string bundleId,
bool success,
string? error = null,
CancellationToken cancellationToken = default)
{
var (notificationType, severity, title, message) = success
? (
AirGapNotificationType.BundleImportCompleted,
NotificationSeverity.Info,
"Bundle Import Completed",
$"Policy pack bundle '{bundleId}' imported successfully")
: (
AirGapNotificationType.BundleImportFailed,
NotificationSeverity.Error,
"Bundle Import Failed",
$"Policy pack bundle '{bundleId}' import failed: {error ?? "unknown error"}");
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: severity,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["bundle_id"] = bundleId,
["success"] = success,
["error"] = error
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
public async Task NotifySealedStateChangeAsync(
string tenantId,
bool isSealed,
CancellationToken cancellationToken = default)
{
var (notificationType, title, message) = isSealed
? (
AirGapNotificationType.EnvironmentSealed,
"Environment Sealed",
"Policy engine environment has been sealed for air-gap operation")
: (
AirGapNotificationType.EnvironmentUnsealed,
"Environment Unsealed",
"Policy engine environment has been unsealed");
var notification = new AirGapNotification(
NotificationId: GenerateNotificationId(),
TenantId: tenantId,
Type: notificationType,
Severity: NotificationSeverity.Info,
Title: title,
Message: message,
OccurredAt: _timeProvider.GetUtcNow(),
Metadata: new Dictionary<string, object?>
{
["sealed"] = isSealed
});
await SendAsync(notification, cancellationToken).ConfigureAwait(false);
}
// Implement IStalenessEventSink to auto-notify on staleness events
public Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default)
{
return NotifyStalenessEventAsync(
evt.TenantId,
evt.Type,
evt.AgeSeconds,
evt.ThresholdSeconds,
cancellationToken);
}
private static string GenerateNotificationId()
{
return $"notify-{Guid.NewGuid():N}"[..24];
}
}
/// <summary>
/// Logging-based notification channel for observability.
/// </summary>
internal sealed class LoggingNotificationChannel : IAirGapNotificationChannel
{
private readonly ILogger<LoggingNotificationChannel> _logger;
public LoggingNotificationChannel(ILogger<LoggingNotificationChannel> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string ChannelName => "Logging";
public Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
var logLevel = notification.Severity switch
{
NotificationSeverity.Critical => LogLevel.Critical,
NotificationSeverity.Error => LogLevel.Error,
NotificationSeverity.Warning => LogLevel.Warning,
_ => LogLevel.Information
};
_logger.Log(
logLevel,
"[{NotificationType}] {Title}: {Message} (tenant={TenantId}, id={NotificationId})",
notification.Type,
notification.Title,
notification.Message,
notification.TenantId,
notification.NotificationId);
return Task.FromResult(true);
}
}
/// <summary>
/// Webhook-based notification channel for external integrations.
/// </summary>
internal sealed class WebhookNotificationChannel : IAirGapNotificationChannel
{
private readonly HttpClient _httpClient;
private readonly string _webhookUrl;
private readonly ILogger<WebhookNotificationChannel> _logger;
public WebhookNotificationChannel(
HttpClient httpClient,
string webhookUrl,
ILogger<WebhookNotificationChannel> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_webhookUrl = webhookUrl ?? throw new ArgumentNullException(nameof(webhookUrl));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public string ChannelName => $"Webhook({_webhookUrl})";
public async Task<bool> DeliverAsync(AirGapNotification notification, CancellationToken cancellationToken = default)
{
try
{
var payload = new
{
notification_id = notification.NotificationId,
tenant_id = notification.TenantId,
type = notification.Type.ToString(),
severity = notification.Severity.ToString(),
title = notification.Title,
message = notification.Message,
occurred_at = notification.OccurredAt.ToString("O"),
metadata = notification.Metadata
};
var response = await _httpClient.PostAsJsonAsync(_webhookUrl, payload, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return true;
}
_logger.LogWarning(
"Webhook delivery returned {StatusCode} for notification {NotificationId}",
response.StatusCode, notification.NotificationId);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Webhook delivery failed for notification {NotificationId} to {WebhookUrl}",
notification.NotificationId, _webhookUrl);
return false;
}
}
}

View File

@@ -0,0 +1,52 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Service for managing sealed-mode operations for policy packs per CONTRACT-SEALED-MODE-004.
/// </summary>
public interface ISealedModeService
{
/// <summary>
/// Gets whether the environment is currently sealed.
/// </summary>
bool IsSealed { get; }
/// <summary>
/// Gets the current sealed state for a tenant.
/// </summary>
Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the sealed status with staleness evaluation.
/// </summary>
Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Seals the environment for a tenant.
/// </summary>
Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Unseals the environment for a tenant.
/// </summary>
Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates staleness for the current time anchor.
/// </summary>
Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Enforces sealed-mode constraints for bundle import operations.
/// </summary>
Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
string tenantId,
string bundlePath,
CancellationToken cancellationToken = default);
/// <summary>
/// Verifies a bundle against trust roots.
/// </summary>
Task<BundleVerifyResponse> VerifyBundleAsync(
BundleVerifyRequest request,
CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Store for sealed-mode state persistence.
/// </summary>
public interface ISealedModeStateStore
{
Task<PolicyPackSealedState?> GetAsync(string tenantId, CancellationToken cancellationToken = default);
Task SaveAsync(PolicyPackSealedState state, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Concurrent;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// In-memory implementation of sealed-mode state store.
/// </summary>
internal sealed class InMemorySealedModeStateStore : ISealedModeStateStore
{
private readonly ConcurrentDictionary<string, PolicyPackSealedState> _states = new(StringComparer.Ordinal);
public Task<PolicyPackSealedState?> GetAsync(string tenantId, CancellationToken cancellationToken = default)
{
_states.TryGetValue(tenantId, out var state);
return Task.FromResult(state);
}
public Task SaveAsync(PolicyPackSealedState state, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(state);
_states[state.TenantId] = state;
return Task.CompletedTask;
}
}

View File

@@ -13,17 +13,20 @@ internal sealed class PolicyPackBundleImportService
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly IPolicyPackBundleStore _store;
private readonly ISealedModeService? _sealedModeService;
private readonly TimeProvider _timeProvider;
private readonly ILogger<PolicyPackBundleImportService> _logger;
public PolicyPackBundleImportService(
IPolicyPackBundleStore store,
TimeProvider timeProvider,
ILogger<PolicyPackBundleImportService> logger)
ILogger<PolicyPackBundleImportService> logger,
ISealedModeService? sealedModeService = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_sealedModeService = sealedModeService;
}
/// <summary>
@@ -38,6 +41,20 @@ internal sealed class PolicyPackBundleImportService
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
// Enforce sealed-mode constraints
if (_sealedModeService is not null)
{
var enforcement = await _sealedModeService.EnforceBundleImportAsync(
tenantId, request.BundlePath, cancellationToken).ConfigureAwait(false);
if (!enforcement.Allowed)
{
_logger.LogWarning("Bundle import blocked by sealed-mode: {Reason}", enforcement.Reason);
throw new InvalidOperationException(
$"Bundle import blocked: {enforcement.Reason}. {enforcement.Remediation}");
}
}
var now = _timeProvider.GetUtcNow();
var importId = GenerateImportId();

View File

@@ -0,0 +1,544 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using StellaOps.Cryptography;
using StellaOps.Policy.RiskProfile.Export;
using StellaOps.Policy.RiskProfile.Hashing;
using StellaOps.Policy.RiskProfile.Models;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Air-gap export/import for risk profiles per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public sealed class RiskProfileAirGapExportService
{
private const string FormatVersion = "1.0";
private const string DomainId = "risk-profiles";
private const string PredicateType = "https://stella.ops/attestation/risk-profile/v1";
private readonly ICryptoHash _cryptoHash;
private readonly TimeProvider _timeProvider;
private readonly ISealedModeService? _sealedModeService;
private readonly RiskProfileHasher _hasher;
private readonly ILogger<RiskProfileAirGapExportService> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public RiskProfileAirGapExportService(
ICryptoHash cryptoHash,
TimeProvider timeProvider,
ILogger<RiskProfileAirGapExportService> logger,
ISealedModeService? sealedModeService = null)
{
_cryptoHash = cryptoHash ?? throw new ArgumentNullException(nameof(cryptoHash));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_sealedModeService = sealedModeService;
_hasher = new RiskProfileHasher(cryptoHash);
}
/// <summary>
/// Creates an air-gap compatible bundle from risk profiles.
/// </summary>
public async Task<RiskProfileAirGapBundle> ExportAsync(
IReadOnlyList<RiskProfileModel> profiles,
AirGapExportRequest request,
string? tenantId = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profiles);
ArgumentNullException.ThrowIfNull(request);
var now = _timeProvider.GetUtcNow();
var bundleId = GenerateBundleId(now);
_logger.LogInformation("Creating air-gap bundle {BundleId} with {Count} profiles",
bundleId, profiles.Count);
// Create exports for each profile
var exports = new List<RiskProfileAirGapExport>();
foreach (var profile in profiles)
{
var contentHash = _hasher.ComputeContentHash(profile);
var profileJson = JsonSerializer.Serialize(profile, JsonOptions);
var artifactDigest = ComputeArtifactDigest(profileJson);
var export = new RiskProfileAirGapExport(
Key: $"profile-{profile.Id}-{profile.Version}",
Format: "json",
ExportId: Guid.NewGuid().ToString("N")[..16],
ProfileId: profile.Id,
ProfileVersion: profile.Version,
CreatedAt: now.ToString("O"),
ArtifactSizeBytes: Encoding.UTF8.GetByteCount(profileJson),
ArtifactDigest: artifactDigest,
ContentHash: contentHash,
ProfileDigest: ComputeProfileDigest(profile),
Attestation: request.SignBundle ? CreateAttestation(now) : null);
exports.Add(export);
}
// Compute bundle-level Merkle root
var merkleRoot = ComputeMerkleRoot(exports);
// Create signature if requested
BundleSignature? signature = null;
if (request.SignBundle)
{
signature = await CreateSignatureAsync(
exports, merkleRoot, request.KeyId, now, cancellationToken).ConfigureAwait(false);
}
return new RiskProfileAirGapBundle(
SchemaVersion: 1,
GeneratedAt: now.ToString("O"),
TargetRepository: request.TargetRepository,
DomainId: DomainId,
DisplayName: request.DisplayName ?? "Risk Profiles Export",
TenantId: tenantId,
Exports: exports.AsReadOnly(),
MerkleRoot: merkleRoot,
Signature: signature,
Profiles: profiles);
}
/// <summary>
/// Imports profiles from an air-gap bundle with sealed-mode enforcement.
/// </summary>
public async Task<RiskProfileAirGapImportResult> ImportAsync(
RiskProfileAirGapBundle bundle,
AirGapImportRequest request,
string tenantId,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(bundle);
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var details = new List<RiskProfileAirGapImportDetail>();
var errors = new List<string>();
// Enforce sealed-mode constraints
if (_sealedModeService is not null && request.EnforceSealedMode)
{
// Pass bundle domain ID as path identifier for sealed-mode enforcement
var enforcement = await _sealedModeService.EnforceBundleImportAsync(
tenantId, $"risk-profile-bundle:{bundle.DomainId}", cancellationToken).ConfigureAwait(false);
if (!enforcement.Allowed)
{
_logger.LogWarning("Air-gap profile import blocked by sealed-mode: {Reason}",
enforcement.Reason);
return new RiskProfileAirGapImportResult(
BundleId: bundle.GeneratedAt,
Success: false,
TotalCount: bundle.Exports.Count,
ImportedCount: 0,
SkippedCount: 0,
ErrorCount: bundle.Exports.Count,
Details: details.AsReadOnly(),
Errors: new[] { $"Sealed-mode blocked: {enforcement.Reason}. {enforcement.Remediation}" },
SignatureVerified: false,
MerkleVerified: false);
}
}
// Verify signature if present and requested
bool? signatureVerified = null;
if (request.VerifySignature && bundle.Signature is not null)
{
signatureVerified = VerifySignature(bundle);
if (!signatureVerified.Value)
{
errors.Add("Bundle signature verification failed");
if (request.RejectOnSignatureFailure)
{
return new RiskProfileAirGapImportResult(
BundleId: bundle.GeneratedAt,
Success: false,
TotalCount: bundle.Exports.Count,
ImportedCount: 0,
SkippedCount: 0,
ErrorCount: bundle.Exports.Count,
Details: details.AsReadOnly(),
Errors: errors.AsReadOnly(),
SignatureVerified: false,
MerkleVerified: null);
}
}
}
// Verify Merkle root
bool? merkleVerified = null;
if (request.VerifyMerkle && !string.IsNullOrEmpty(bundle.MerkleRoot))
{
var computedMerkle = ComputeMerkleRoot(bundle.Exports.ToList());
merkleVerified = string.Equals(computedMerkle, bundle.MerkleRoot, StringComparison.OrdinalIgnoreCase);
if (!merkleVerified.Value)
{
errors.Add("Merkle root verification failed - bundle may have been tampered with");
if (request.RejectOnMerkleFailure)
{
return new RiskProfileAirGapImportResult(
BundleId: bundle.GeneratedAt,
Success: false,
TotalCount: bundle.Exports.Count,
ImportedCount: 0,
SkippedCount: 0,
ErrorCount: bundle.Exports.Count,
Details: details.AsReadOnly(),
Errors: errors.AsReadOnly(),
SignatureVerified: signatureVerified,
MerkleVerified: false);
}
}
}
// Verify individual exports
var importedCount = 0;
var skippedCount = 0;
var errorCount = 0;
if (bundle.Profiles is not null)
{
for (var i = 0; i < bundle.Exports.Count; i++)
{
var export = bundle.Exports[i];
var profile = bundle.Profiles.FirstOrDefault(p =>
p.Id == export.ProfileId && p.Version == export.ProfileVersion);
if (profile is null)
{
details.Add(new RiskProfileAirGapImportDetail(
ProfileId: export.ProfileId,
Version: export.ProfileVersion,
Status: AirGapImportStatus.Error,
Message: "Profile data missing from bundle"));
errorCount++;
continue;
}
// Verify content hash
var computedHash = _hasher.ComputeContentHash(profile);
if (!string.Equals(computedHash, export.ContentHash, StringComparison.OrdinalIgnoreCase))
{
details.Add(new RiskProfileAirGapImportDetail(
ProfileId: export.ProfileId,
Version: export.ProfileVersion,
Status: AirGapImportStatus.Error,
Message: "Content hash mismatch - profile may have been modified"));
errorCount++;
continue;
}
// Import successful
details.Add(new RiskProfileAirGapImportDetail(
ProfileId: export.ProfileId,
Version: export.ProfileVersion,
Status: AirGapImportStatus.Imported,
Message: null));
importedCount++;
}
}
var success = errorCount == 0 && errors.Count == 0;
_logger.LogInformation(
"Air-gap import completed: success={Success}, imported={Imported}, skipped={Skipped}, errors={Errors}",
success, importedCount, skippedCount, errorCount);
return new RiskProfileAirGapImportResult(
BundleId: bundle.GeneratedAt,
Success: success,
TotalCount: bundle.Exports.Count,
ImportedCount: importedCount,
SkippedCount: skippedCount,
ErrorCount: errorCount,
Details: details.AsReadOnly(),
Errors: errors.AsReadOnly(),
SignatureVerified: signatureVerified,
MerkleVerified: merkleVerified);
}
/// <summary>
/// Verifies bundle integrity without importing.
/// </summary>
public AirGapBundleVerification Verify(RiskProfileAirGapBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
var signatureValid = bundle.Signature is not null && VerifySignature(bundle);
var merkleValid = !string.IsNullOrEmpty(bundle.MerkleRoot) &&
string.Equals(ComputeMerkleRoot(bundle.Exports.ToList()), bundle.MerkleRoot, StringComparison.OrdinalIgnoreCase);
var exportDigestResults = new List<ExportDigestVerification>();
if (bundle.Profiles is not null)
{
foreach (var export in bundle.Exports)
{
var profile = bundle.Profiles.FirstOrDefault(p =>
p.Id == export.ProfileId && p.Version == export.ProfileVersion);
var valid = profile is not null &&
string.Equals(_hasher.ComputeContentHash(profile), export.ContentHash, StringComparison.OrdinalIgnoreCase);
exportDigestResults.Add(new ExportDigestVerification(
ExportKey: export.Key,
ProfileId: export.ProfileId,
Valid: valid));
}
}
return new AirGapBundleVerification(
SignatureValid: signatureValid,
MerkleValid: merkleValid,
ExportDigests: exportDigestResults.AsReadOnly(),
AllValid: signatureValid && merkleValid && exportDigestResults.All(e => e.Valid));
}
private bool VerifySignature(RiskProfileAirGapBundle bundle)
{
if (bundle.Signature is null)
{
return false;
}
// Compute expected signature from exports and Merkle root
var data = ComputeSignatureData(bundle.Exports.ToList(), bundle.MerkleRoot ?? "");
var expectedSignature = ComputeHmacSignature(data, GetSigningKey(bundle.Signature.KeyId));
return string.Equals(expectedSignature, bundle.Signature.Path, StringComparison.OrdinalIgnoreCase);
}
private async Task<BundleSignature> CreateSignatureAsync(
IReadOnlyList<RiskProfileAirGapExport> exports,
string merkleRoot,
string? keyId,
DateTimeOffset signedAt,
CancellationToken cancellationToken)
{
var data = ComputeSignatureData(exports.ToList(), merkleRoot);
var signatureValue = ComputeHmacSignature(data, GetSigningKey(keyId));
return new BundleSignature(
Path: signatureValue,
Algorithm: "HMAC-SHA256",
KeyId: keyId ?? "default",
Provider: "stellaops",
SignedAt: signedAt.ToString("O"));
}
private static string ComputeSignatureData(List<RiskProfileAirGapExport> exports, string merkleRoot)
{
var sb = new StringBuilder();
foreach (var export in exports.OrderBy(e => e.Key))
{
sb.Append(export.ContentHash);
sb.Append('|');
}
sb.Append(merkleRoot);
return sb.ToString();
}
private static string ComputeHmacSignature(string data, string key)
{
var keyBytes = Encoding.UTF8.GetBytes(key);
var dataBytes = Encoding.UTF8.GetBytes(data);
using var hmac = new HMACSHA256(keyBytes);
var hashBytes = hmac.ComputeHash(dataBytes);
return Convert.ToHexStringLower(hashBytes);
}
private string ComputeMerkleRoot(List<RiskProfileAirGapExport> exports)
{
if (exports.Count == 0)
{
return string.Empty;
}
// Leaf hashes from artifact digests
var leaves = exports
.OrderBy(e => e.Key)
.Select(e => e.ArtifactDigest.Replace("sha256:", "", StringComparison.OrdinalIgnoreCase))
.ToList();
// Build Merkle tree
while (leaves.Count > 1)
{
var nextLevel = new List<string>();
for (var i = 0; i < leaves.Count; i += 2)
{
if (i + 1 < leaves.Count)
{
var combined = leaves[i] + leaves[i + 1];
nextLevel.Add(ComputeSha256(combined));
}
else
{
nextLevel.Add(leaves[i]);
}
}
leaves = nextLevel;
}
return $"sha256:{leaves[0]}";
}
private string ComputeArtifactDigest(string content)
{
return $"sha256:{_cryptoHash.ComputeHashHexForPurpose(
Encoding.UTF8.GetBytes(content), HashPurpose.Content)}";
}
private string ComputeProfileDigest(RiskProfileModel profile)
{
var json = JsonSerializer.Serialize(profile, JsonOptions);
return ComputeArtifactDigest(json);
}
private static string ComputeSha256(string input)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
return Convert.ToHexStringLower(bytes);
}
private AttestationDescriptor CreateAttestation(DateTimeOffset signedAt)
{
return new AttestationDescriptor(
PredicateType: PredicateType,
RekorLocation: null,
EnvelopeDigest: null,
SignedAt: signedAt.ToString("O"));
}
private static string GenerateBundleId(DateTimeOffset timestamp)
{
return $"rpab-{timestamp:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..24];
}
private static string GetSigningKey(string? keyId)
{
// In production, this would look up the key from secure storage
return "stellaops-airgap-signing-key-change-in-production";
}
}
#region Models
/// <summary>
/// Air-gap bundle for risk profiles per CONTRACT-MIRROR-BUNDLE-003.
/// </summary>
public sealed record RiskProfileAirGapBundle(
[property: JsonPropertyName("schemaVersion")] int SchemaVersion,
[property: JsonPropertyName("generatedAt")] string GeneratedAt,
[property: JsonPropertyName("targetRepository")] string? TargetRepository,
[property: JsonPropertyName("domainId")] string DomainId,
[property: JsonPropertyName("displayName")] string? DisplayName,
[property: JsonPropertyName("tenantId")] string? TenantId,
[property: JsonPropertyName("exports")] IReadOnlyList<RiskProfileAirGapExport> Exports,
[property: JsonPropertyName("merkleRoot")] string? MerkleRoot,
[property: JsonPropertyName("signature")] BundleSignature? Signature,
[property: JsonPropertyName("profiles")] IReadOnlyList<RiskProfileModel>? Profiles);
/// <summary>
/// Export entry for a risk profile.
/// </summary>
public sealed record RiskProfileAirGapExport(
[property: JsonPropertyName("key")] string Key,
[property: JsonPropertyName("format")] string Format,
[property: JsonPropertyName("exportId")] string ExportId,
[property: JsonPropertyName("profileId")] string ProfileId,
[property: JsonPropertyName("profileVersion")] string ProfileVersion,
[property: JsonPropertyName("createdAt")] string CreatedAt,
[property: JsonPropertyName("artifactSizeBytes")] long ArtifactSizeBytes,
[property: JsonPropertyName("artifactDigest")] string ArtifactDigest,
[property: JsonPropertyName("contentHash")] string ContentHash,
[property: JsonPropertyName("profileDigest")] string? ProfileDigest,
[property: JsonPropertyName("attestation")] AttestationDescriptor? Attestation);
/// <summary>
/// Request to create an air-gap export.
/// </summary>
public sealed record AirGapExportRequest(
bool SignBundle = true,
string? KeyId = null,
string? TargetRepository = null,
string? DisplayName = null);
/// <summary>
/// Request to import from an air-gap bundle.
/// </summary>
public sealed record AirGapImportRequest(
bool VerifySignature = true,
bool VerifyMerkle = true,
bool EnforceSealedMode = true,
bool RejectOnSignatureFailure = true,
bool RejectOnMerkleFailure = true);
/// <summary>
/// Result of air-gap import.
/// </summary>
public sealed record RiskProfileAirGapImportResult(
string BundleId,
bool Success,
int TotalCount,
int ImportedCount,
int SkippedCount,
int ErrorCount,
IReadOnlyList<RiskProfileAirGapImportDetail> Details,
IReadOnlyList<string> Errors,
bool? SignatureVerified,
bool? MerkleVerified);
/// <summary>
/// Import detail for a single profile.
/// </summary>
public sealed record RiskProfileAirGapImportDetail(
string ProfileId,
string Version,
AirGapImportStatus Status,
string? Message);
/// <summary>
/// Import status values.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<AirGapImportStatus>))]
public enum AirGapImportStatus
{
Imported,
Skipped,
Error
}
/// <summary>
/// Bundle verification result.
/// </summary>
public sealed record AirGapBundleVerification(
bool SignatureValid,
bool MerkleValid,
IReadOnlyList<ExportDigestVerification> ExportDigests,
bool AllValid);
/// <summary>
/// Export digest verification result.
/// </summary>
public sealed record ExportDigestVerification(
string ExportKey,
string ProfileId,
bool Valid);
#endregion

View File

@@ -0,0 +1,255 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Error codes for sealed-mode operations per CONTRACT-SEALED-MODE-004.
/// </summary>
public static class SealedModeErrorCodes
{
/// <summary>Time anchor missing when required.</summary>
public const string AnchorMissing = "ERR_AIRGAP_001";
/// <summary>Time anchor staleness breached.</summary>
public const string StalenessBreach = "ERR_AIRGAP_002";
/// <summary>Time anchor staleness warning threshold exceeded.</summary>
public const string StalenessWarning = "ERR_AIRGAP_003";
/// <summary>Bundle signature verification failed.</summary>
public const string SignatureInvalid = "ERR_AIRGAP_004";
/// <summary>Bundle format or structure invalid.</summary>
public const string BundleInvalid = "ERR_AIRGAP_005";
/// <summary>Egress blocked in sealed mode.</summary>
public const string EgressBlocked = "ERR_AIRGAP_006";
/// <summary>Seal operation failed.</summary>
public const string SealFailed = "ERR_AIRGAP_007";
/// <summary>Unseal operation failed.</summary>
public const string UnsealFailed = "ERR_AIRGAP_008";
/// <summary>Trust roots not found or invalid.</summary>
public const string TrustRootsInvalid = "ERR_AIRGAP_009";
/// <summary>Bundle import blocked by policy.</summary>
public const string ImportBlocked = "ERR_AIRGAP_010";
/// <summary>Policy hash mismatch.</summary>
public const string PolicyHashMismatch = "ERR_AIRGAP_011";
/// <summary>Startup blocked due to sealed-mode requirements.</summary>
public const string StartupBlocked = "ERR_AIRGAP_012";
}
/// <summary>
/// Problem types for sealed-mode errors (RFC 7807 compatible).
/// </summary>
public static class SealedModeProblemTypes
{
private const string BaseUri = "https://stellaops.org/problems/airgap";
public static readonly string AnchorMissing = $"{BaseUri}/anchor-missing";
public static readonly string StalenessBreach = $"{BaseUri}/staleness-breach";
public static readonly string StalenessWarning = $"{BaseUri}/staleness-warning";
public static readonly string SignatureInvalid = $"{BaseUri}/signature-invalid";
public static readonly string BundleInvalid = $"{BaseUri}/bundle-invalid";
public static readonly string EgressBlocked = $"{BaseUri}/egress-blocked";
public static readonly string SealFailed = $"{BaseUri}/seal-failed";
public static readonly string UnsealFailed = $"{BaseUri}/unseal-failed";
public static readonly string TrustRootsInvalid = $"{BaseUri}/trust-roots-invalid";
public static readonly string ImportBlocked = $"{BaseUri}/import-blocked";
public static readonly string PolicyHashMismatch = $"{BaseUri}/policy-hash-mismatch";
public static readonly string StartupBlocked = $"{BaseUri}/startup-blocked";
}
/// <summary>
/// Structured error details for sealed-mode problems.
/// </summary>
public sealed record SealedModeErrorDetails(
string Code,
string Message,
string? Remediation = null,
string? DocumentationUrl = null,
IDictionary<string, object?>? Extensions = null);
/// <summary>
/// Represents a sealed-mode violation that occurred during an operation.
/// </summary>
public class SealedModeException : Exception
{
public SealedModeException(
string code,
string message,
string? remediation = null)
: base(message)
{
Code = code;
Remediation = remediation;
}
public SealedModeException(
string code,
string message,
Exception innerException,
string? remediation = null)
: base(message, innerException)
{
Code = code;
Remediation = remediation;
}
/// <summary>
/// Gets the error code for this exception.
/// </summary>
public string Code { get; }
/// <summary>
/// Gets optional remediation guidance.
/// </summary>
public string? Remediation { get; }
/// <summary>
/// Creates an exception for time anchor missing.
/// </summary>
public static SealedModeException AnchorMissing(string tenantId) =>
new(SealedModeErrorCodes.AnchorMissing,
$"Time anchor required for tenant '{tenantId}' in sealed mode",
"Provide a verified time anchor using POST /system/airgap/seal");
/// <summary>
/// Creates an exception for staleness breach.
/// </summary>
public static SealedModeException StalenessBreach(string tenantId, int ageSeconds, int thresholdSeconds) =>
new(SealedModeErrorCodes.StalenessBreach,
$"Time anchor staleness breached for tenant '{tenantId}': age {ageSeconds}s exceeds threshold {thresholdSeconds}s",
"Refresh time anchor before continuing operations");
/// <summary>
/// Creates an exception for egress blocked.
/// </summary>
public static SealedModeException EgressBlocked(string destination, string? reason = null) =>
new(SealedModeErrorCodes.EgressBlocked,
$"Egress to '{destination}' blocked in sealed mode" + (reason is not null ? $": {reason}" : ""),
"Add destination to egress allowlist or unseal environment");
/// <summary>
/// Creates an exception for bundle import blocked.
/// </summary>
public static SealedModeException ImportBlocked(string bundlePath, string reason) =>
new(SealedModeErrorCodes.ImportBlocked,
$"Bundle import blocked: {reason}",
"Ensure time anchor is fresh and bundle is properly signed");
/// <summary>
/// Creates an exception for invalid bundle.
/// </summary>
public static SealedModeException BundleInvalid(string bundlePath, string reason) =>
new(SealedModeErrorCodes.BundleInvalid,
$"Bundle '{bundlePath}' is invalid: {reason}",
"Verify bundle format and content integrity");
/// <summary>
/// Creates an exception for signature verification failure.
/// </summary>
public static SealedModeException SignatureInvalid(string bundlePath, string reason) =>
new(SealedModeErrorCodes.SignatureInvalid,
$"Bundle signature verification failed for '{bundlePath}': {reason}",
"Ensure bundle is signed by trusted key and trust roots are properly configured");
/// <summary>
/// Creates an exception for startup blocked.
/// </summary>
public static SealedModeException StartupBlocked(string reason) =>
new(SealedModeErrorCodes.StartupBlocked,
$"Startup blocked in sealed mode: {reason}",
"Resolve sealed-mode requirements before starting the service");
}
/// <summary>
/// Result helper for converting sealed-mode errors to HTTP problem details.
/// </summary>
public static class SealedModeResultHelper
{
/// <summary>
/// Creates a problem result for a sealed-mode exception.
/// </summary>
public static IResult ToProblem(SealedModeException ex)
{
var (problemType, statusCode) = GetProblemTypeAndStatus(ex.Code);
return Results.Problem(
title: GetTitle(ex.Code),
detail: ex.Message,
type: problemType,
statusCode: statusCode,
extensions: new Dictionary<string, object?>
{
["code"] = ex.Code,
["remediation"] = ex.Remediation
});
}
/// <summary>
/// Creates a problem result for a generic sealed-mode error.
/// </summary>
public static IResult ToProblem(
string code,
string message,
string? remediation = null,
int? statusCode = null)
{
var (problemType, defaultStatusCode) = GetProblemTypeAndStatus(code);
return Results.Problem(
title: GetTitle(code),
detail: message,
type: problemType,
statusCode: statusCode ?? defaultStatusCode,
extensions: new Dictionary<string, object?>
{
["code"] = code,
["remediation"] = remediation
});
}
private static (string ProblemType, int StatusCode) GetProblemTypeAndStatus(string code)
{
return code switch
{
SealedModeErrorCodes.AnchorMissing => (SealedModeProblemTypes.AnchorMissing, 412),
SealedModeErrorCodes.StalenessBreach => (SealedModeProblemTypes.StalenessBreach, 412),
SealedModeErrorCodes.StalenessWarning => (SealedModeProblemTypes.StalenessWarning, 200), // Warning only
SealedModeErrorCodes.SignatureInvalid => (SealedModeProblemTypes.SignatureInvalid, 422),
SealedModeErrorCodes.BundleInvalid => (SealedModeProblemTypes.BundleInvalid, 422),
SealedModeErrorCodes.EgressBlocked => (SealedModeProblemTypes.EgressBlocked, 403),
SealedModeErrorCodes.SealFailed => (SealedModeProblemTypes.SealFailed, 500),
SealedModeErrorCodes.UnsealFailed => (SealedModeProblemTypes.UnsealFailed, 500),
SealedModeErrorCodes.TrustRootsInvalid => (SealedModeProblemTypes.TrustRootsInvalid, 422),
SealedModeErrorCodes.ImportBlocked => (SealedModeProblemTypes.ImportBlocked, 403),
SealedModeErrorCodes.PolicyHashMismatch => (SealedModeProblemTypes.PolicyHashMismatch, 409),
SealedModeErrorCodes.StartupBlocked => (SealedModeProblemTypes.StartupBlocked, 503),
_ => ("about:blank", 500)
};
}
private static string GetTitle(string code)
{
return code switch
{
SealedModeErrorCodes.AnchorMissing => "Time anchor required",
SealedModeErrorCodes.StalenessBreach => "Staleness threshold breached",
SealedModeErrorCodes.StalenessWarning => "Staleness warning",
SealedModeErrorCodes.SignatureInvalid => "Signature verification failed",
SealedModeErrorCodes.BundleInvalid => "Invalid bundle",
SealedModeErrorCodes.EgressBlocked => "Egress blocked",
SealedModeErrorCodes.SealFailed => "Seal operation failed",
SealedModeErrorCodes.UnsealFailed => "Unseal operation failed",
SealedModeErrorCodes.TrustRootsInvalid => "Trust roots invalid",
SealedModeErrorCodes.ImportBlocked => "Import blocked",
SealedModeErrorCodes.PolicyHashMismatch => "Policy hash mismatch",
SealedModeErrorCodes.StartupBlocked => "Startup blocked",
_ => "Sealed mode error"
};
}
}

View File

@@ -0,0 +1,114 @@
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Sealed-mode state for policy packs per CONTRACT-SEALED-MODE-004.
/// </summary>
public sealed record PolicyPackSealedState(
string TenantId,
bool IsSealed,
string? PolicyHash,
TimeAnchorInfo? TimeAnchor,
StalenessBudget StalenessBudget,
DateTimeOffset LastTransitionAt);
/// <summary>
/// Time anchor information for sealed-mode operations.
/// </summary>
public sealed record TimeAnchorInfo(
DateTimeOffset AnchorTime,
string Source,
string Format,
string? SignatureFingerprint,
string? TokenDigest);
/// <summary>
/// Staleness budget configuration.
/// </summary>
public sealed record StalenessBudget(
int WarningSeconds,
int BreachSeconds)
{
public static StalenessBudget Default => new(3600, 7200);
}
/// <summary>
/// Result of staleness evaluation.
/// </summary>
public sealed record StalenessEvaluation(
int AgeSeconds,
int WarningSeconds,
int BreachSeconds,
bool IsBreached,
int RemainingSeconds)
{
public bool IsWarning => AgeSeconds >= WarningSeconds && !IsBreached;
}
/// <summary>
/// Request to seal the environment.
/// </summary>
public sealed record SealRequest(
string? PolicyHash,
TimeAnchorInfo? TimeAnchor,
StalenessBudget? StalenessBudget);
/// <summary>
/// Response from seal/unseal operations.
/// </summary>
public sealed record SealResponse(
bool Sealed,
DateTimeOffset LastTransitionAt);
/// <summary>
/// Sealed status response.
/// </summary>
public sealed record SealedStatusResponse(
bool Sealed,
string TenantId,
StalenessEvaluation? Staleness,
TimeAnchorInfo? TimeAnchor,
string? PolicyHash);
/// <summary>
/// Bundle verification request.
/// </summary>
public sealed record BundleVerifyRequest(
string BundlePath,
string? TrustRootsPath);
/// <summary>
/// Bundle verification response.
/// </summary>
public sealed record BundleVerifyResponse(
bool Valid,
BundleVerificationResult VerificationResult);
/// <summary>
/// Detailed verification result.
/// </summary>
public sealed record BundleVerificationResult(
bool DsseValid,
bool TufValid,
bool MerkleValid,
string? Error);
/// <summary>
/// Sealed-mode enforcement result for bundle operations.
/// </summary>
public sealed record SealedModeEnforcementResult(
bool Allowed,
string? Reason,
string? Remediation);
/// <summary>
/// Sealed-mode telemetry constants.
/// </summary>
public static class SealedModeTelemetry
{
public const string MetricSealedGauge = "policy_airgap_sealed";
public const string MetricAnchorDriftSeconds = "policy_airgap_anchor_drift_seconds";
public const string MetricAnchorExpirySeconds = "policy_airgap_anchor_expiry_seconds";
public const string MetricSealTotal = "policy_airgap_seal_total";
public const string MetricUnsealTotal = "policy_airgap_unseal_total";
public const string MetricBundleImportBlocked = "policy_airgap_bundle_import_blocked_total";
}

View File

@@ -0,0 +1,216 @@
using Microsoft.Extensions.Logging;
using StellaOps.AirGap.Policy;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Service for managing sealed-mode operations for policy packs per CONTRACT-SEALED-MODE-004.
/// </summary>
internal sealed class SealedModeService : ISealedModeService
{
private readonly ISealedModeStateStore _store;
private readonly IEgressPolicy _egressPolicy;
private readonly TimeProvider _timeProvider;
private readonly ILogger<SealedModeService> _logger;
public SealedModeService(
ISealedModeStateStore store,
IEgressPolicy egressPolicy,
TimeProvider timeProvider,
ILogger<SealedModeService> logger)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_egressPolicy = egressPolicy ?? throw new ArgumentNullException(nameof(egressPolicy));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public bool IsSealed => _egressPolicy.IsSealed;
public async Task<PolicyPackSealedState> GetStateAsync(string tenantId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var state = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (state is null)
{
// Return default unsealed state
return new PolicyPackSealedState(
TenantId: tenantId,
IsSealed: _egressPolicy.IsSealed,
PolicyHash: null,
TimeAnchor: null,
StalenessBudget: StalenessBudget.Default,
LastTransitionAt: DateTimeOffset.MinValue);
}
return state;
}
public async Task<SealedStatusResponse> GetStatusAsync(string tenantId, CancellationToken cancellationToken = default)
{
var state = await GetStateAsync(tenantId, cancellationToken).ConfigureAwait(false);
var staleness = await EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
return new SealedStatusResponse(
Sealed: state.IsSealed,
TenantId: state.TenantId,
Staleness: staleness,
TimeAnchor: state.TimeAnchor,
PolicyHash: state.PolicyHash);
}
public async Task<SealResponse> SealAsync(string tenantId, SealRequest request, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentNullException.ThrowIfNull(request);
var now = _timeProvider.GetUtcNow();
_logger.LogInformation("Sealing environment for tenant {TenantId} with policy hash {PolicyHash}",
tenantId, request.PolicyHash ?? "(none)");
var state = new PolicyPackSealedState(
TenantId: tenantId,
IsSealed: true,
PolicyHash: request.PolicyHash,
TimeAnchor: request.TimeAnchor,
StalenessBudget: request.StalenessBudget ?? StalenessBudget.Default,
LastTransitionAt: now);
await _store.SaveAsync(state, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Environment sealed for tenant {TenantId} at {TransitionAt}",
tenantId, now);
return new SealResponse(Sealed: true, LastTransitionAt: now);
}
public async Task<SealResponse> UnsealAsync(string tenantId, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
var now = _timeProvider.GetUtcNow();
var existing = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Unsealing environment for tenant {TenantId}", tenantId);
var state = new PolicyPackSealedState(
TenantId: tenantId,
IsSealed: false,
PolicyHash: existing?.PolicyHash,
TimeAnchor: existing?.TimeAnchor,
StalenessBudget: existing?.StalenessBudget ?? StalenessBudget.Default,
LastTransitionAt: now);
await _store.SaveAsync(state, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Environment unsealed for tenant {TenantId} at {TransitionAt}",
tenantId, now);
return new SealResponse(Sealed: false, LastTransitionAt: now);
}
public async Task<StalenessEvaluation?> EvaluateStalenessAsync(string tenantId, CancellationToken cancellationToken = default)
{
var state = await _store.GetAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (state?.TimeAnchor is null)
{
return null;
}
var now = _timeProvider.GetUtcNow();
var age = now - state.TimeAnchor.AnchorTime;
var ageSeconds = (int)age.TotalSeconds;
var breachSeconds = state.StalenessBudget.BreachSeconds;
var remainingSeconds = Math.Max(0, breachSeconds - ageSeconds);
return new StalenessEvaluation(
AgeSeconds: ageSeconds,
WarningSeconds: state.StalenessBudget.WarningSeconds,
BreachSeconds: breachSeconds,
IsBreached: ageSeconds >= breachSeconds,
RemainingSeconds: remainingSeconds);
}
public async Task<SealedModeEnforcementResult> EnforceBundleImportAsync(
string tenantId,
string bundlePath,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
ArgumentException.ThrowIfNullOrWhiteSpace(bundlePath);
// If not in sealed mode at the infrastructure level, allow bundle import
if (!_egressPolicy.IsSealed)
{
_logger.LogDebug("Bundle import allowed: environment not sealed");
return new SealedModeEnforcementResult(Allowed: true, Reason: null, Remediation: null);
}
// In sealed mode, verify the tenant state
var state = await GetStateAsync(tenantId, cancellationToken).ConfigureAwait(false);
// Check staleness
var staleness = await EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (staleness?.IsBreached == true)
{
_logger.LogWarning(
"Bundle import blocked: staleness breached for tenant {TenantId} (age={AgeSeconds}s, breach={BreachSeconds}s) [{ErrorCode}]",
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, SealedModeErrorCodes.StalenessBreach);
return new SealedModeEnforcementResult(
Allowed: false,
Reason: $"[{SealedModeErrorCodes.StalenessBreach}] Time anchor staleness breached ({staleness.AgeSeconds}s > {staleness.BreachSeconds}s threshold)",
Remediation: "Refresh time anchor before importing bundles in sealed mode");
}
// Warn if approaching staleness threshold
if (staleness?.IsWarning == true)
{
_logger.LogWarning(
"Staleness warning for tenant {TenantId}: age={AgeSeconds}s approaching breach at {BreachSeconds}s [{ErrorCode}]",
tenantId, staleness.AgeSeconds, staleness.BreachSeconds, SealedModeErrorCodes.StalenessWarning);
}
// Bundle imports are allowed in sealed mode (they're the approved ingestion path)
_logger.LogDebug("Bundle import allowed in sealed mode for tenant {TenantId}", tenantId);
return new SealedModeEnforcementResult(Allowed: true, Reason: null, Remediation: null);
}
public Task<BundleVerifyResponse> VerifyBundleAsync(
BundleVerifyRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ArgumentException.ThrowIfNullOrWhiteSpace(request.BundlePath);
// This would integrate with StellaOps.AirGap.Importer DsseVerifier
// For now, perform basic verification
_logger.LogInformation("Verifying bundle at {BundlePath} with trust roots {TrustRootsPath}",
request.BundlePath, request.TrustRootsPath ?? "(none)");
if (!File.Exists(request.BundlePath))
{
return Task.FromResult(new BundleVerifyResponse(
Valid: false,
VerificationResult: new BundleVerificationResult(
DsseValid: false,
TufValid: false,
MerkleValid: false,
Error: $"Bundle file not found: {request.BundlePath}")));
}
// Placeholder: Full verification would check DSSE signatures, TUF metadata, and Merkle proofs
return Task.FromResult(new BundleVerifyResponse(
Valid: true,
VerificationResult: new BundleVerificationResult(
DsseValid: true,
TufValid: true,
MerkleValid: true,
Error: null)));
}
}

View File

@@ -0,0 +1,327 @@
using Microsoft.Extensions.Logging;
using StellaOps.Policy.Engine.Telemetry;
namespace StellaOps.Policy.Engine.AirGap;
/// <summary>
/// Staleness signaling status for health endpoints.
/// </summary>
public sealed record StalenessSignalStatus(
bool IsHealthy,
bool HasWarning,
bool IsBreach,
int? AgeSeconds,
int? RemainingSeconds,
string? Message);
/// <summary>
/// Fallback mode configuration for when primary data is stale.
/// </summary>
public sealed record FallbackConfiguration(
bool Enabled,
FallbackStrategy Strategy,
int? CacheTimeoutSeconds,
bool AllowDegradedOperation);
/// <summary>
/// Available fallback strategies when data becomes stale.
/// </summary>
public enum FallbackStrategy
{
/// <summary>No fallback - fail hard on staleness.</summary>
None,
/// <summary>Use cached data with warning.</summary>
Cache,
/// <summary>Use last-known-good state.</summary>
LastKnownGood,
/// <summary>Degrade to read-only mode.</summary>
ReadOnly,
/// <summary>Require manual intervention.</summary>
ManualIntervention
}
/// <summary>
/// Staleness event for signaling.
/// </summary>
public sealed record StalenessEvent(
string TenantId,
StalenessEventType Type,
int AgeSeconds,
int ThresholdSeconds,
DateTimeOffset OccurredAt,
string? Message);
/// <summary>
/// Types of staleness events.
/// </summary>
public enum StalenessEventType
{
/// <summary>Staleness warning threshold crossed.</summary>
Warning,
/// <summary>Staleness breach threshold crossed.</summary>
Breach,
/// <summary>Staleness recovered (time anchor refreshed).</summary>
Recovered,
/// <summary>Time anchor missing.</summary>
AnchorMissing
}
/// <summary>
/// Interface for staleness event subscribers.
/// </summary>
public interface IStalenessEventSink
{
Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default);
}
/// <summary>
/// Service for managing staleness signaling and fallback behavior.
/// </summary>
public interface IStalenessSignalingService
{
/// <summary>
/// Gets the current staleness signal status for a tenant.
/// </summary>
Task<StalenessSignalStatus> GetSignalStatusAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the fallback configuration for a tenant.
/// </summary>
Task<FallbackConfiguration> GetFallbackConfigurationAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Checks if fallback mode is active for a tenant.
/// </summary>
Task<bool> IsFallbackActiveAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Evaluates staleness and raises events if thresholds are crossed.
/// </summary>
Task EvaluateAndSignalAsync(string tenantId, CancellationToken cancellationToken = default);
/// <summary>
/// Signals that the time anchor has been refreshed.
/// </summary>
Task SignalRecoveryAsync(string tenantId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Default implementation of staleness signaling service.
/// </summary>
internal sealed class StalenessSignalingService : IStalenessSignalingService
{
private readonly ISealedModeService _sealedModeService;
private readonly IEnumerable<IStalenessEventSink> _eventSinks;
private readonly TimeProvider _timeProvider;
private readonly ILogger<StalenessSignalingService> _logger;
// Track last signaled state per tenant to avoid duplicate events
private readonly Dictionary<string, StalenessEventType?> _lastSignaledState = new();
private readonly object _stateLock = new();
public StalenessSignalingService(
ISealedModeService sealedModeService,
IEnumerable<IStalenessEventSink> eventSinks,
TimeProvider timeProvider,
ILogger<StalenessSignalingService> logger)
{
_sealedModeService = sealedModeService ?? throw new ArgumentNullException(nameof(sealedModeService));
_eventSinks = eventSinks ?? [];
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<StalenessSignalStatus> GetSignalStatusAsync(string tenantId, CancellationToken cancellationToken = default)
{
var staleness = await _sealedModeService.EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
if (staleness is null)
{
// No time anchor - cannot evaluate staleness
return new StalenessSignalStatus(
IsHealthy: !_sealedModeService.IsSealed, // Healthy if not sealed (anchor not required)
HasWarning: _sealedModeService.IsSealed,
IsBreach: false,
AgeSeconds: null,
RemainingSeconds: null,
Message: _sealedModeService.IsSealed ? "Time anchor not configured" : null);
}
var message = staleness.IsBreached
? $"Staleness breach: data is {staleness.AgeSeconds}s old (threshold: {staleness.BreachSeconds}s)"
: staleness.IsWarning
? $"Staleness warning: data is {staleness.AgeSeconds}s old (breach at: {staleness.BreachSeconds}s)"
: null;
return new StalenessSignalStatus(
IsHealthy: !staleness.IsBreached,
HasWarning: staleness.IsWarning,
IsBreach: staleness.IsBreached,
AgeSeconds: staleness.AgeSeconds,
RemainingSeconds: staleness.RemainingSeconds,
Message: message);
}
public Task<FallbackConfiguration> GetFallbackConfigurationAsync(string tenantId, CancellationToken cancellationToken = default)
{
// Default fallback configuration - could be extended to read from configuration
return Task.FromResult(new FallbackConfiguration(
Enabled: true,
Strategy: FallbackStrategy.LastKnownGood,
CacheTimeoutSeconds: 3600,
AllowDegradedOperation: true));
}
public async Task<bool> IsFallbackActiveAsync(string tenantId, CancellationToken cancellationToken = default)
{
var status = await GetSignalStatusAsync(tenantId, cancellationToken).ConfigureAwait(false);
var config = await GetFallbackConfigurationAsync(tenantId, cancellationToken).ConfigureAwait(false);
return config.Enabled && (status.IsBreach || status.HasWarning);
}
public async Task EvaluateAndSignalAsync(string tenantId, CancellationToken cancellationToken = default)
{
var staleness = await _sealedModeService.EvaluateStalenessAsync(tenantId, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
StalenessEventType? currentState = null;
string? message = null;
if (staleness is null && _sealedModeService.IsSealed)
{
currentState = StalenessEventType.AnchorMissing;
message = "Time anchor not configured in sealed mode";
}
else if (staleness?.IsBreached == true)
{
currentState = StalenessEventType.Breach;
message = $"Staleness breach: {staleness.AgeSeconds}s > {staleness.BreachSeconds}s";
}
else if (staleness?.IsWarning == true)
{
currentState = StalenessEventType.Warning;
message = $"Staleness warning: {staleness.AgeSeconds}s approaching {staleness.BreachSeconds}s";
}
// Only signal if state changed
lock (_stateLock)
{
_lastSignaledState.TryGetValue(tenantId, out var lastState);
if (currentState == lastState)
{
return; // No change
}
_lastSignaledState[tenantId] = currentState;
}
if (currentState.HasValue)
{
var evt = new StalenessEvent(
TenantId: tenantId,
Type: currentState.Value,
AgeSeconds: staleness?.AgeSeconds ?? 0,
ThresholdSeconds: staleness?.BreachSeconds ?? 0,
OccurredAt: now,
Message: message);
await RaiseEventAsync(evt, cancellationToken).ConfigureAwait(false);
// Record telemetry
PolicyEngineTelemetry.RecordStalenessEvent(tenantId, currentState.Value.ToString());
}
}
public async Task SignalRecoveryAsync(string tenantId, CancellationToken cancellationToken = default)
{
var now = _timeProvider.GetUtcNow();
lock (_stateLock)
{
_lastSignaledState.TryGetValue(tenantId, out var lastState);
if (lastState is null)
{
return; // Nothing to recover from
}
_lastSignaledState[tenantId] = null;
}
var evt = new StalenessEvent(
TenantId: tenantId,
Type: StalenessEventType.Recovered,
AgeSeconds: 0,
ThresholdSeconds: 0,
OccurredAt: now,
Message: "Time anchor refreshed, staleness recovered");
await RaiseEventAsync(evt, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Staleness recovered for tenant {TenantId}", tenantId);
}
private async Task RaiseEventAsync(StalenessEvent evt, CancellationToken cancellationToken)
{
_logger.LogInformation(
"Staleness event {EventType} for tenant {TenantId}: {Message}",
evt.Type, evt.TenantId, evt.Message);
foreach (var sink in _eventSinks)
{
try
{
await sink.OnStalenessEventAsync(evt, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to deliver staleness event to sink {SinkType}", sink.GetType().Name);
}
}
}
}
/// <summary>
/// Logging-based staleness event sink for observability.
/// </summary>
internal sealed class LoggingStalenessEventSink : IStalenessEventSink
{
private readonly ILogger<LoggingStalenessEventSink> _logger;
public LoggingStalenessEventSink(ILogger<LoggingStalenessEventSink> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public Task OnStalenessEventAsync(StalenessEvent evt, CancellationToken cancellationToken = default)
{
var logLevel = evt.Type switch
{
StalenessEventType.Breach => LogLevel.Error,
StalenessEventType.Warning => LogLevel.Warning,
StalenessEventType.AnchorMissing => LogLevel.Warning,
StalenessEventType.Recovered => LogLevel.Information,
_ => LogLevel.Information
};
_logger.Log(
logLevel,
"Staleness {EventType} for tenant {TenantId}: age={AgeSeconds}s, threshold={ThresholdSeconds}s - {Message}",
evt.Type,
evt.TenantId,
evt.AgeSeconds,
evt.ThresholdSeconds,
evt.Message);
return Task.CompletedTask;
}
}