Sealed Mode Contract (AIRGAP-57)

Contract ID: CONTRACT-SEALED-MODE-004 Version: 1.0 Status: Published Last Updated: 2025-12-05

Overview

This contract defines the sealed-mode operation contract for air-gapped StellaOps deployments. It covers sealing/unsealing state transitions, staleness detection, time anchoring, and egress-policy enforcement.

Audience: AirGap Controller, AirGap Policy/Time, and Policy Engine implementers, plus operators integrating sealed-mode status into deployment readiness checks.

Implementation References

Data Models

AirGapState

The core sealed-mode state model (StellaOps.AirGap.Controller.Domain.AirGapState). It is a per-tenant singleton (Id = "singleton").

public sealed record AirGapState
{
    public const string SingletonId = "singleton";

    public string Id { get; init; } = SingletonId;        // "singleton"
    public string TenantId { get; init; } = "default";
    public bool Sealed { get; init; } = false;
    public string? PolicyHash { get; init; } = null;
    public TimeAnchor TimeAnchor { get; init; } = TimeAnchor.Unknown;
    public DateTimeOffset LastTransitionAt { get; init; } = DateTimeOffset.MinValue;
    public StalenessBudget StalenessBudget { get; init; } = StalenessBudget.Default;

    // Drift baseline (wall-clock minus anchor time) captured at seal.
    public long DriftBaselineSeconds { get; init; } = 0;

    // Optional per-content staleness budgets (e.g. advisories, vex, policy).
    public IReadOnlyDictionary<string, StalenessBudget> ContentBudgets { get; init; }
        = new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
}

The persisted record carries DriftBaselineSeconds and ContentBudgets; both are surfaced by the status endpoint. The state is durable in PostgreSQL outside the Testing environment (see Program.cs — the controller refuses to start without Postgres:AirGap:ConnectionString / ConnectionStrings:Default).

TimeAnchor

Cryptographically verified time reference (StellaOps.AirGap.Time.Models.TimeAnchor).

public sealed record TimeAnchor(
    DateTimeOffset AnchorTime,
    string Source,                 // e.g. "roughtime:<keyId>", "rfc3161:<keyId>", or "unknown"
    string Format,                 // "Roughtime" | "RFC3161" | "unknown"
    string SignatureFingerprint,   // 16-hex-char prefix of SHA-256 over the trust-root key / signer cert
    string TokenDigest);           // lowercase hex SHA-256 of the raw token bytes

// TimeAnchor.Unknown = new(DateTimeOffset.MinValue, "unknown", "unknown", "", "")

JSON (PascalCase records serialize via the service’s configured naming policy; the field semantics are what is contractual):

{
  "anchorTime": "2025-12-05T10:00:00Z",
  "source": "roughtime:tsa-key-1",
  "format": "Roughtime",
  "signatureFingerprint": "9f86d081884c7d65",
  "tokenDigest": "e3b0c44298fc1c14..."
}

NOTE: signatureFingerprint and tokenDigest are bare lowercase hex (no sha256: prefix). tokenDigest is the full SHA-256; signatureFingerprint is a 16-char prefix.

StalenessBudget

Defines staleness thresholds (StellaOps.AirGap.Time.Models.StalenessBudget). Budgets are seconds and validated: both must be non-negative and warningSeconds <= breachSeconds.

{
  "warningSeconds": 3600,
  "breachSeconds": 7200
}
FieldDefaultDescription
warningSeconds3600Warning threshold (1 hour)
breachSeconds7200Breach threshold (2 hours)

StalenessBudget.Default = (3600, 7200).

StalenessEvaluation

Result of staleness calculation (StellaOps.AirGap.Time.Models.StalenessEvaluation). When the anchor time is DateTimeOffset.MinValue (no anchor), StalenessEvaluation.Unknown is returned (all zero, both flags false).

public sealed record StalenessEvaluation(
    long AgeSeconds,
    long WarningSeconds,
    long BreachSeconds,
    bool IsWarning,
    bool IsBreach)
{
    public long SecondsRemaining => Math.Max(0, BreachSeconds - AgeSeconds);
}
{
  "ageSeconds": 1800,
  "warningSeconds": 3600,
  "breachSeconds": 7200,
  "isWarning": false,
  "isBreach": false,
  "secondsRemaining": 5400
}

The age is computed as max(0, now - anchorTime) in seconds; isWarning / isBreach are age >= warningSeconds / age >= breachSeconds. There is no is_breached or remaining_seconds field — the fields are isBreach and the computed secondsRemaining (relative to the breach threshold, not warning).

API Endpoints

All /system/airgap/* endpoints live in AirGapEndpoints.MapAirGapEndpoints. The whole group requires the airgap:status:read scope, and individual routes layer additional scope policies on top. Tenant is resolved from the stellaops:tenant / tid claim or the x-tenant-id (legacy tid) header; a missing tenant yields 400 {"error":"tenant_required"}, and a claim/header mismatch yields 403.

Get Status

GET /system/airgap/status
Authorization: Bearer <token with airgap:status:read scope>

Response: 200 OK
{
  "tenantId": "default",
  "sealed": true,
  "policyHash": "sha256:...",
  "timeAnchor": { ... },
  "staleness": {
    "ageSeconds": 1800,
    "warningSeconds": 3600,
    "breachSeconds": 7200,
    "isWarning": false,
    "isBreach": false,
    "secondsRemaining": 5400
  },
  "driftSeconds": 1800,
  "driftBaselineSeconds": 0,
  "secondsRemaining": 5400,
  "contentStaleness": {
    "advisories": { "ageSeconds": 1800, "secondsRemaining": 5400, "isWarning": false, "isBreach": false }
  },
  "lastTransitionAt": "2025-12-05T10:00:00Z",
  "evaluatedAt": "2025-12-05T10:30:00Z"
}

The status shape is AirGapStatusResponse: tenantId, sealed, policyHash, timeAnchor, staleness, driftSeconds (= staleness.ageSeconds), driftBaselineSeconds, secondsRemaining (= staleness.secondsRemaining), contentStaleness, lastTransitionAt, evaluatedAt.

Seal Environment

POST /system/airgap/seal
Content-Type: application/json
Authorization: Bearer <token with airgap:seal scope>

{
  "policyHash": "sha256:...",          // REQUIRED
  "timeAnchor": { ... },               // optional; defaults to TimeAnchor.Unknown
  "stalenessBudget": {                 // optional; defaults to (3600, 7200)
    "warningSeconds": 3600,
    "breachSeconds": 7200
  },
  "contentBudgets": {                  // optional per-content budgets
    "advisories": { "warningSeconds": 3600, "breachSeconds": 7200 }
  }
}

Response: 200 OK  (full AirGapStatusResponse — same shape as GET /status)

policyHash is the only required field; missing/blank → 400 validation problem. Invalid budgets (warningSeconds > breachSeconds or negatives) are rejected with field-level validation errors. The response is the full status response, not a {sealed, last_transition_at} fragment.

Unseal Environment

POST /system/airgap/unseal
Authorization: Bearer <token with airgap:seal scope>

Response: 200 OK  (full AirGapStatusResponse; staleness is Unknown after unseal)

Seal and unseal share the airgap:seal scope (policy AirGap.Seal). There is no separate unseal scope.

Verify (deterministic replay)

POST /system/airgap/verify
Content-Type: application/json
Authorization: Bearer <token with airgap:status:read scope>

{
  "depth": "FullRecompute",            // HashOnly | FullRecompute | PolicyFreeze (default FullRecompute)
  "manifestSha256": "...",             // REQUIRED (expected)
  "bundleSha256": "...",               // REQUIRED (expected)
  "computedManifestSha256": "...",     // optional; defaults to manifestSha256
  "computedBundleSha256": "...",       // optional; defaults to bundleSha256
  "manifestCreatedAt": "2025-12-05T10:00:00Z",  // REQUIRED (non-min)
  "stalenessWindowHours": 24,          // must be >= 0
  "bundlePolicyHash": "sha256:...",    // optional; required for PolicyFreeze depth
  "sealedPolicyHash": "sha256:..."     // optional; defaults to the sealed state's PolicyHash
}

Response: 200 OK
{ "valid": true, "reason": "full-recompute-passed" }

Response: 400 Bad Request   (verification failure)
{ "valid": false, "reason": "manifest-hash-drift" }

The verify endpoint runs ReplayVerifier (in StellaOps.AirGap.Importer), which compares expected vs computed manifest/bundle SHA-256, enforces the manifest staleness window, and — at PolicyFreeze depth — requires the bundle policy hash to equal the sealed policy hash. Possible reason values include hash-missing, manifest-hash-drift, bundle-hash-drift, manifest-stale, policy-hash-missing, policy-hash-drift, and the success strings hash-only-passed, full-recompute-passed, policy-freeze-passed.

NOT IMPLEMENTED as documented in prior revisions: the verify endpoint does not accept bundle_path / trust_roots_path, and it does not return a verification_result with dsse_valid / tuf_valid / merkle_valid. Those DSSE / TUF / Merkle primitives exist as separate validators in StellaOps.AirGap.Importer (DsseVerifier, TufMetadataValidator, MerkleRootCalculator) and are exercised by the bundle-import / startup-trust flow, not by this HTTP endpoint.

Time Anchor endpoints (Time service)

The time-anchor read/write surface is served by StellaOps.AirGap.Time (TimeStatusController), not the Controller service:

GET  /api/v1/time/status?tenantId=<id>     # requires airgap:status:read
POST /api/v1/time/anchor                    # requires airgap:seal (AnchorWrite policy)

POST /api/v1/time/anchor ingests a signed time token (hex-encoded), verifies it against the configured trust roots, and persists the resulting TimeAnchor plus staleness budget. Request-supplied trust roots are only honoured in Development/Testing and only when AllowRequestSuppliedTrustRoots is set.

Egress Policy

EgressPolicy / EgressPolicyOptions

The policy object itself (StellaOps.AirGap.Policy.EgressPolicy) exposes only the mode and a derived IsSealed flag; the allow-list and network toggles live on EgressPolicyOptions.

public sealed partial class EgressPolicy : IEgressPolicy
{
    public bool IsSealed => Mode == EgressPolicyMode.Sealed;
    public EgressPolicyMode Mode { get; }   // Unsealed = 0 | Sealed = 1
    public EgressDecision Evaluate(EgressRequest request);
    public ValueTask<EgressDecision> EvaluateAsync(EgressRequest request, CancellationToken ct = default);
    public void EnsureAllowed(EgressRequest request);                 // throws AirGapEgressBlockedException
    public ValueTask EnsureAllowedAsync(EgressRequest request, CancellationToken ct = default);
}

public sealed class EgressPolicyOptions
{
    public EgressPolicyMode Mode { get; set; } = EgressPolicyMode.Unsealed;
    public bool AllowLoopback { get; set; } = true;          // localhost/loopback allowed by default
    public bool AllowPrivateNetworks { get; set; } = false;  // RFC1918/ULA blocked by default
    public IReadOnlyList<EgressRule> AllowRules { get; }      // consulted only when Sealed
    public string? RemediationDocumentationUrl { get; set; }
    public string? SupportContact { get; set; }
}

EgressRequest / EgressDecision

public readonly record struct EgressRequest
{
    public string Component { get; }     // requesting subsystem, e.g. "PolicyEngine", "ExportCenter"
    public string Intent { get; }        // e.g. "advisories-sync", "telemetry-export"
    public string? Operation { get; }    // optional free-text description (NOT an HTTP verb)
    public Uri Destination { get; }      // absolute URI (required)
    public EgressTransport Transport { get; }  // inferred from scheme when Any
}

public sealed record EgressDecision
{
    public bool IsAllowed { get; }
    public string? Reason { get; }       // human-readable explanation when blocked
    public string? Remediation { get; }  // operator guidance when blocked
    public static EgressDecision Allowed { get; }
    public static EgressDecision Blocked(string reason, string remediation);
}

The decision’s Reason is a human sentence (e.g. "Destination 'api.github.com' is not present in the sealed-mode allow list."), not a machine error code. The stable error code "AIRGAP_EGRESS_BLOCKED" is exposed as AirGapEgressBlockedException.ErrorCode, surfaced when EnsureAllowed/EnsureAllowedAsync throws.

Enforcement

EgressPolicy.Evaluate:

  1. A request without an absolute Destination URI is blocked.
  2. When Unsealed, every well-formed request is allowed (decisions are advisory).
  3. When Sealed:
    • Loopback destinations are allowed iff AllowLoopback (default true).
    • Private-network destinations (IPv4 RFC1918 10/8, 172.16/12, 192.168/16; IPv6 link-local, site-local, ULA fc00::/7) are allowed iff AllowPrivateNetworks (default false).
    • Otherwise the request must match a configured EgressRule; if none match, it is blocked.

Time Verification

Time tokens are verified by StellaOps.AirGap.Time against configured TimeTrustRoots. Supported formats are Roughtime and Rfc3161 (TimeTokenFormat).

Roughtime Verification (RoughtimeVerifier)

  1. Compute the token digest (SHA-256, lowercase hex) and parse the Roughtime response (midpoint, radius, signature, signed message).
  2. Verify the Ed25519 signature against each ed25519 trust root (32-byte public key).
  3. Anchor time = Unix epoch + midpoint microseconds; Source = "roughtime:<keyId>", Format = "Roughtime", fingerprint = 16-char SHA-256 prefix of the trust-root public key.

RFC 3161 Verification (Rfc3161Verifier)

  1. Decode the SignedCms structure and check the signature.
  2. Require a signer certificate and extract the signing time.
  3. Validate the signer certificate against the trust roots (optionally with offline revocation when options.Offline); Source = "rfc3161:<keyId>", Format = "RFC3161", fingerprint = 16-char SHA-256 prefix of the signer certificate.

Startup Diagnostics

AirGapStartupDiagnosticsHostedService runs at startup. If the tenant is not sealed, it logs and skips. When sealed, it collects failures and throws (blocking startup) if any are present, emitting airgap_startup_blocked_total:

  1. Egress allowlist present and non-empty (egress-allowlist-missing / egress-allowlist-empty).
  2. Time anchor present (time-anchor-missing) and not stale (time-anchor-stale when isBreach).
  3. Trust materials (TUF root/snapshot/timestamp) load and validate (trust:<reason>).
  4. Key-rotation policy validates when pending keys exist (rotation:<reason>).

Health endpoints

GET /healthz   -> 200 OK   (liveness; always Ok while the process responds; anonymous)
GET /readyz    -> 200 / 503 (readiness; applies all registered health checks; anonymous)

CORRECTION: the readiness route is /readyz, not /healthz/ready. Both health routes are anonymous (no Authority token required). Note that sealed-startup failures cause the process to fail to start (the hosted service throws), rather than reporting 503 from a running process.

Telemetry

Metrics

Meter: StellaOps.AirGap.Controller (v1.0.0). All metrics carry a tenant tag.

MetricTypeDescription
airgap_seal_totalcounterTotal seal operations (tags: tenant, sealed)
airgap_unseal_totalcounterTotal unseal operations (tags: tenant, sealed)
airgap_startup_blocked_totalcounterBlocked sealed-startup attempts (tags: tenant, reason)
airgap_time_anchor_age_secondsobservable gaugeLatest time-anchor age in seconds (per tenant)
airgap_staleness_budget_secondsobservable gaugeLatest staleness breach budget in seconds (per tenant)

CORRECTION: prior revisions listed airgap_sealed, airgap_anchor_drift_seconds, and airgap_anchor_expiry_seconds. Those names are not emitted. There is no airgap_sealed gauge; the anchor-age and budget gauges are airgap_time_anchor_age_seconds and airgap_staleness_budget_seconds respectively.

Structured Logging

Log events emitted by AirGapTelemetry include airgap.sealed, airgap.unsealed, airgap.status.read, and airgap.startup.validation (passed/failed). Example fields:

airgap.sealed tenant=default policy_hash=sha256:... anchor_source=roughtime:tsa-key-1
              anchor_digest=e3b0c4... age_seconds=1800

Authority Scopes

Scopes are defined in StellaOps.Auth.Abstractions.StellaOpsScopes (the JWT authorization surface).

ScopeConstEndpoint(s) / PolicyStatus
airgap:status:readStellaOpsScopes.AirgapStatusReadGET /system/airgap/status, POST /system/airgap/verify (AirGap.Verify — read-only, shares the read scope), GET /api/v1/time/status, group baseline (AirGap.StatusRead)Implemented
airgap:sealStellaOpsScopes.AirgapSealPOST /system/airgap/seal, /unseal, POST /api/v1/time/anchor (AirGap.Seal / AirGapTime.AnchorWrite)Implemented
airgap:importStellaOpsScopes.AirgapImportPolicy AirGap.Import registered, but no Controller endpoint consumes itReserved (no live endpoint)

RESOLVED — there is no airgap:verify scope. POST /system/airgap/verify runs under the AirGap.Verify policy, which requires StellaOpsScopes.AirgapStatusRead (airgap:status:read, Program.cs:59-69): verification is a read-only integrity check (ReplayVerificationService compares supplied replay evidence against the sealed policy hash and mutates no state), so it shares the air-gap read scope, mirroring how the Attestor verify surface collapses to attest:read. The historical airgap:verify literal was never in the canonical catalog and was granted to no client, which made the endpoint unreachable by any real caller; collapsing it onto the already-granted airgap:status:read fixed that without a grant change. The standard plugin grants (devops/etc/authority/plugins/standard.yaml) grant airgap:seal and airgap:status:read.

FLAG — airgap:import is granted by the catalog and has a registered authorization policy, but the Controller exposes no /import route (MapAirGapEndpoints registers only status, seal, unseal, verify). The scope is reserved for the offline bundle-import flow.

Unblocks

This contract unblocks the following tasks: