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
- Controller:
src/AirGap/StellaOps.AirGap.Controller/ - Time:
src/AirGap/StellaOps.AirGap.Time/ - Policy:
src/AirGap/StellaOps.AirGap.Policy/ - Importer (replay/verify primitives):
src/AirGap/StellaOps.AirGap.Importer/ - Documentation:
docs/modules/airgap/guides/sealing-and-egress.md,docs/modules/airgap/guides/staleness-and-time.md
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
DriftBaselineSecondsandContentBudgets; both are surfaced by the status endpoint. The state is durable in PostgreSQL outside theTestingenvironment (seeProgram.cs— the controller refuses to start withoutPostgres: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:
signatureFingerprintandtokenDigestare bare lowercase hex (nosha256:prefix).tokenDigestis the full SHA-256;signatureFingerprintis 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
}
| Field | Default | Description |
|---|---|---|
warningSeconds | 3600 | Warning threshold (1 hour) |
breachSeconds | 7200 | Breach 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/isBreachareage >= warningSeconds/age >= breachSeconds. There is nois_breachedorremaining_secondsfield — the fields areisBreachand the computedsecondsRemaining(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)
policyHashis the only required field; missing/blank →400validation problem. Invalid budgets (warningSeconds > breachSecondsor 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:sealscope (policyAirGap.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 averification_resultwithdsse_valid/tuf_valid/merkle_valid. Those DSSE / TUF / Merkle primitives exist as separate validators inStellaOps.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
Reasonis 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 asAirGapEgressBlockedException.ErrorCode, surfaced whenEnsureAllowed/EnsureAllowedAsyncthrows.
Enforcement
EgressPolicy.Evaluate:
- A request without an absolute
DestinationURI is blocked. - When Unsealed, every well-formed request is allowed (decisions are advisory).
- When Sealed:
- Loopback destinations are allowed iff
AllowLoopback(defaulttrue). - Private-network destinations (IPv4 RFC1918
10/8,172.16/12,192.168/16; IPv6 link-local, site-local, ULAfc00::/7) are allowed iffAllowPrivateNetworks(defaultfalse). - Otherwise the request must match a configured
EgressRule; if none match, it is blocked.
- Loopback destinations are allowed iff
Time Verification
Time tokens are verified by StellaOps.AirGap.Time against configured TimeTrustRoots. Supported formats are Roughtime and Rfc3161 (TimeTokenFormat).
Roughtime Verification (RoughtimeVerifier)
- Compute the token digest (SHA-256, lowercase hex) and parse the Roughtime response (midpoint, radius, signature, signed message).
- Verify the Ed25519 signature against each
ed25519trust root (32-byte public key). - 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)
- Decode the
SignedCmsstructure and check the signature. - Require a signer certificate and extract the signing time.
- 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:
- Egress allowlist present and non-empty (
egress-allowlist-missing/egress-allowlist-empty). - Time anchor present (
time-anchor-missing) and not stale (time-anchor-stalewhenisBreach). - Trust materials (TUF root/snapshot/timestamp) load and validate (
trust:<reason>). - 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 reporting503from a running process.
Telemetry
Metrics
Meter: StellaOps.AirGap.Controller (v1.0.0). All metrics carry a tenant tag.
| Metric | Type | Description |
|---|---|---|
airgap_seal_total | counter | Total seal operations (tags: tenant, sealed) |
airgap_unseal_total | counter | Total unseal operations (tags: tenant, sealed) |
airgap_startup_blocked_total | counter | Blocked sealed-startup attempts (tags: tenant, reason) |
airgap_time_anchor_age_seconds | observable gauge | Latest time-anchor age in seconds (per tenant) |
airgap_staleness_budget_seconds | observable gauge | Latest staleness breach budget in seconds (per tenant) |
CORRECTION: prior revisions listed
airgap_sealed,airgap_anchor_drift_seconds, andairgap_anchor_expiry_seconds. Those names are not emitted. There is noairgap_sealedgauge; the anchor-age and budget gauges areairgap_time_anchor_age_secondsandairgap_staleness_budget_secondsrespectively.
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).
| Scope | Const | Endpoint(s) / Policy | Status |
|---|---|---|---|
airgap:status:read | StellaOpsScopes.AirgapStatusRead | GET /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:seal | StellaOpsScopes.AirgapSeal | POST /system/airgap/seal, /unseal, POST /api/v1/time/anchor (AirGap.Seal / AirGapTime.AnchorWrite) | Implemented |
airgap:import | StellaOpsScopes.AirgapImport | Policy AirGap.Import registered, but no Controller endpoint consumes it | Reserved (no live endpoint) |
RESOLVED — there is no
airgap:verifyscope.POST /system/airgap/verifyruns under theAirGap.Verifypolicy, which requiresStellaOpsScopes.AirgapStatusRead(airgap:status:read,Program.cs:59-69): verification is a read-only integrity check (ReplayVerificationServicecompares 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 toattest:read. The historicalairgap:verifyliteral 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-grantedairgap:status:readfixed that without a grant change. The standard plugin grants (devops/etc/authority/plugins/standard.yaml) grantairgap:sealandairgap:status:read.
FLAG —
airgap:importis granted by the catalog and has a registered authorization policy, but the Controller exposes no/importroute (MapAirGapEndpointsregisters onlystatus,seal,unseal,verify). The scope is reserved for the offline bundle-import flow.
Unblocks
This contract unblocks the following tasks:
- POLICY-AIRGAP-57-001
- POLICY-AIRGAP-57-002
- POLICY-AIRGAP-58-001
Related Contracts
- Mirror Bundle Contract — bundle format for sealed import.
- Verification Policy Contract — attestation verification.
- Sealed Install Enforcement Contract — superseded design; its §8 documents the same AirGap Controller / Policy Engine surface from a pack-enforcement angle.
- Sealing and egress guide and staleness and time guide — operator-facing companions.
