Mirror Bundle Contract (AIRGAP-56)
Contract ID: CONTRACT-MIRROR-BUNDLE-003 Version: 1.0 Status: Published Last Updated: 2025-12-05
Part of the Stella Ops Contracts index. Audience: Excititor, Concelier, and AirGap implementers who produce, register, or validate mirror bundles, and operators running offline/air-gapped synchronization. Related: Sealed Mode, Verification Policy, Export Bundle.
Reconciliation note (verified against source 2026-05-30). This contract was reconciled against the live implementation. Several field names, the export-format set, the bundle signature algorithm, and the entire Registration API section were corrected to match code. Where the original document described shapes that do not exist in any source, they are marked NOT IMPLEMENTED or replaced with the real contract. Ground-truth symbols are cited inline.
Overview
This contract defines the mirror bundle format used for air-gap/offline operation. Mirror bundles package VEX advisories and vulnerability-feed exports for transport to sealed environments, register them in a per-tenant catalog, and expose provenance/staleness for synchronization monitoring.
The contract spans three services:
- Excititor (
src/Concelier/StellaOps.Excititor.WebService) — produces VEX mirror exports, exposes the mirror distribution domains, and registers/queries imported bundles. - Concelier (
src/Concelier/StellaOps.Concelier.WebService) — aggregates the air-gap advisory bundle catalog, manages bundle sources, and runs the timeline-emitting import. - AirGap Importer / Controller (
src/AirGap/) — validates a staged bundle (TUF → DSSE → Merkle → key-rotation → referrer → version monotonicity) and owns the seal/unseal/verify lifecycle.
Implementation References
- VEX export manifest model:
src/Concelier/__Libraries/StellaOps.Excititor.Core/VexExportManifest.cs(VexExportManifest,VexContentAddress,VexAttestationMetadata,VexExportFormat) - StellaOps-mirror connector bundle document:
src/Concelier/__Libraries/StellaOps.Concelier.Connector.StellaOpsMirror/Internal/MirrorBundleDocument.cs(MirrorBundleDocument) - Mirror distribution endpoints / index DTOs:
src/Concelier/StellaOps.Excititor.WebService/Endpoints/MirrorEndpoints.cs - Mirror bundle registration / query DTOs:
src/Concelier/StellaOps.Excititor.WebService/Endpoints/MirrorRegistrationEndpoints.cs,src/Concelier/StellaOps.Excititor.WebService/Contracts/AirgapMirrorContracts.cs - Concelier air-gap catalog & import:
src/Concelier/StellaOps.Concelier.WebService/Extensions/AirGapEndpointExtensions.cs - Import validation:
src/AirGap/StellaOps.AirGap.Importer/Validation/(ImportValidator,DsseVerifier,TufMetadataValidator,MerkleRootCalculator,RootRotationPolicy,ReferrerValidator,VersionMonotonicityChecker) - Seal/unseal/verify controller:
src/AirGap/StellaOps.AirGap.Controller/Endpoints/AirGapEndpoints.cs - Mirror distribution config:
src/Concelier/__Libraries/StellaOps.Excititor.Core/MirrorDistributionOptions.cs - Documentation:
docs/modules/airgap/guides/mirror-bundles.md
NOT IMPLEMENTED / FLAGGED: the previously referenced JSON schema
docs/modules/airgap/schemas/mirror-bundle.schema.jsondoes not exist in the repository. Thedocs/modules/airgap/schemas/directory containsmanifest.schema.json,receipt.schema.json,av-report.schema.json, and the time-anchor schemas only. The reference has been removed; the canonical shapes are the C# record types cited above.Note also that
src/AirGap/StellaOps.AirGap.Importer/is the import validator (no bundle DTOs), andsrc/Mirror/StellaOps.Mirror.Creator/is a separate mirror-sync planner (MirrorSyncPlan/MirrorSyncResultinMirrorModels.cs) that does not define the bundle/export shapes below.
Bundle Structure
Mirror domain index (MirrorDomainIndex)
The export-listing surface served at GET /excititor/mirror/domains/{domainId}/index is the closest top-level “bundle of exports” object. It is built in MirrorEndpoints.HandleDomainIndexAsync and serialized with VexCanonicalJsonSerializer.
{
"id": "vex-advisories",
"displayName": "VEX Advisories",
"generatedAt": "2025-12-05T10:00:00Z",
"exports": [
{ ... }
]
}
| Field | Type | Source field | Description |
|---|---|---|---|
id | string | MirrorDomainIndex.Id | Domain identifier (operator-configured) |
displayName | string | MirrorDomainIndex.DisplayName | Human-readable name (falls back to id) |
generatedAt | datetime | MirrorDomainIndex.GeneratedAt | ISO-8601 generation timestamp |
exports | array | MirrorDomainIndex.Exports | Export index entries (see below) |
NOT IMPLEMENTED: the originally documented top-level object with
schemaVersion,targetRepository, and adomainIdfield on the index does not exist. A separateMirrorBundleDocument(the StellaOps-mirror connector bundle, not the export index) does carryschemaVersion,generatedAt,targetRepository,domainId,displayName,advisoryCount,advisories[], andsources[]— those are advisories-mirror fields, not VEX-export fields.
Export entry (MirrorExportIndexEntry / MirrorExportMetadata)
Each export in the index, and the standalone export metadata served at GET /excititor/mirror/domains/{domainId}/exports/{exportKey}, is projected from a VexExportManifest.
{
"exportKey": "vex-openvex-all",
"exportId": "550e8400-e29b-41d4-a716-446655440000",
"querySignature": "format=openvex&sourceVendor=anchore",
"format": "openvex",
"createdAt": "2025-12-05T10:00:00Z",
"artifact": { "algorithm": "sha256", "digest": "7d9cd5f1a2a0dd9a41a2c43a5b7d8a0bcd9e34cf39b3f43a70595c834f0a4aee" },
"sizeBytes": 1048576,
"sourceProviders": ["anchore", "github", "redhat"],
"consensusRevision": "rev-2025-12-05-001",
"attestation": {
"predicateType": "https://stella.ops/attestation/vex-export/v1",
"rekorLocation": "https://rekor.sigstore.dev/api/v1/log/entries/...",
"envelopeDigest": "sha256:...",
"signedAt": "2025-12-05T10:00:01Z"
},
"status": null
}
| Field | Type | Source symbol | Description |
|---|---|---|---|
exportKey | string | MirrorExportIndexEntry.ExportKey | Operator-defined export key within the domain |
exportId | string | VexExportManifest.ExportId | Unique export identifier |
querySignature | string | VexExportManifest.QuerySignature (VexQuerySignature.Value) | Deterministic key=value&... filter signature |
format | string | VexExportManifest.Format | Export format, lower-cased (see Export Formats) |
createdAt | datetime | VexExportManifest.CreatedAt | Export creation timestamp |
artifact | object | VexExportManifest.Artifact (VexContentAddress) | Content address with algorithm + digest |
sizeBytes | integer | VexExportManifest.SizeBytes | Artifact size in bytes |
sourceProviders | array | VexExportManifest.SourceProviders | Contributing providers (sorted) — present on MirrorExportMetadata, omitted from the slim index entry |
consensusRevision | string? | VexExportManifest.ConsensusRevision | Optional consensus revision id |
attestation | object? | VexExportManifest.Attestation (MirrorExportAttestation) | Optional attestation descriptor |
status | string? | MirrorExportIndexEntry.Status | Non-null on the index entry when the export is unavailable (e.g. manifest_not_found, invalid_export_configuration) |
The full VexExportManifest carries additional fields not surfaced on the mirror index/metadata DTOs: claimCount, fromCache, policyRevisionId, policyDigest, consensusDigest (VexContentAddress), scoreDigest (VexContentAddress), and quietProvenance[].
Corrected field names (doc was wrong):
artifactSizeBytes→sizeBytes;artifactDigest(asha256:...string) →artifact(a{algorithm, digest}content address); thekeyfield →exportKey.policyRevisionId,policyDigest,consensusDigest, andscoreDigestlive onVexExportManifestbut are not emitted on the mirror endpoint DTOs.
Export Formats (VexExportFormat)
| Format value | Enum member | Description |
|---|---|---|
json | VexExportFormat.Json | Standard JSON |
jsonl | VexExportFormat.JsonLines | Newline-delimited JSON |
openvex | VexExportFormat.OpenVex | OpenVEX format |
csaf | VexExportFormat.Csaf | CSAF VEX format |
cyclonedx | VexExportFormat.CycloneDx | CycloneDX VEX format |
Corrected: the enum is defined in
VexExportManifest.cswith exactly these five members. The previously listedspdxformat does not exist, andndjsonis spelledjsonl(VexExportFormat.JsonLines).
Attestation descriptor (VexAttestationMetadata / MirrorExportAttestation)
Attestation metadata for signed exports. On the mirror DTOs this is flattened to MirrorExportAttestation(PredicateType, RekorLocation, EnvelopeDigest, SignedAt), where RekorLocation is taken from VexAttestationMetadata.Rekor?.Location.
{
"predicateType": "https://stella.ops/attestation/vex-export/v1",
"rekorLocation": "https://rekor.sigstore.dev/...",
"envelopeDigest": "sha256:...",
"signedAt": "2025-12-05T10:00:01Z"
}
The underlying VexAttestationMetadata.Rekor (VexRekorReference) additionally carries apiVersion, logIndex, and inclusionProofUri when present.
Bundle signature (DSSE)
Bundles are signed with a DSSE envelope, not a detached signature record. Verification is performed by DsseVerifier using RSA-PSS / SHA-256 (PS256) over the DSSE pre-authentication encoding (PAE).
NOT IMPLEMENTED / corrected: the previously documented
BundleSignatureobject withpath,algorithm: ES256,keyId,provider,signedAtdoes not match any verification path.DsseVerifier.IsAlgorithmAllowedaccepts onlyPS256/RSASSA-PSS-SHA256/RSA-PSS-SHA256;ES256is rejected.ES256appears only as an example default string in theMirrorSigningOptions.Algorithmdoc-comment (publisher side) and is not the enforced verification algorithm. The DSSE envelope itself useskeyidon each signature (DsseEnvelope); trust is established viaTrustRootConfig.TrustedKeyFingerprints+PublicKeys.
Mirror Domains
Domains are operator-configured under the Excititor:Mirror configuration section (MirrorDistributionOptions.SectionName), not a fixed enumeration. Each MirrorDomainOptions defines:
| Config key | Type | Default | Description |
|---|---|---|---|
Id | string | — | Domain identifier |
DisplayName | string | Id | Human-readable name |
RequireAuthentication | bool | false | When true, index/metadata/download require an authenticated principal |
MaxIndexRequestsPerHour | int | 120 | Per-domain index rate limit |
MaxDownloadRequestsPerHour | int | 600 | Per-domain download rate limit |
Exports[] | array | — | MirrorExportOptions (Key, Format, Filters, Sort, Limit, Offset, View) |
Top-level Excititor:Mirror keys include Enabled, OutputRoot, DirectoryName (default mirror), TargetRepository, RefreshIntervalMinutes (default 60), AutoRefreshEnabled, and a Signing block (Enabled, Algorithm, KeyId, Provider, KeyPath).
NOT IMPLEMENTED: the previously documented fixed “Standard domain identifiers” table (
vex-advisories,vulnerability-feeds,policy-packs,sbom-catalog) is not enforced anywhere in code. Domain IDs are whatever operators configure. Those strings are illustrative only.
Validation Requirements
Validation is orchestrated by ImportValidator.ValidateAsync in strict order; any failure quarantines the bundle (FileSystemQuarantineService) and returns a BundleValidationResult with a reason code.
Order of checks (as implemented)
- TUF metadata (
TufMetadataValidator.ValidateoverRootJson/SnapshotJson/TimestampJson) — failure codeTUF_INVALID. - DSSE signature (
DsseVerifier.Verify) — RSA-PSS/SHA256, trusted-fingerprint check, allowed-algorithm check, trust-window check; failure codeDSSE_INVALID. - Merkle root (
MerkleRootCalculator.ComputeRootoverPayloadEntries) compared against themerkleRoot/merkle_rootfield inmanifest.json— failure codesHASH_MISMATCH,MERKLE_ROOT_MISSING,MERKLE_ROOT_MISMATCH. - Trust-root rotation (
RootRotationPolicy.Validateover active/pending keys + approver IDs) — failure codeROTATION_INVALID. - Referrer validation (
ReferrerValidator.Validate) — only for bundle typesmirror-bundleandoffline-kit; tracks missing/checksum/size mismatches and orphaned referrers — failure codeREFERRER_VALIDATION_FAILED. - Version monotonicity (
VersionMonotonicityChecker.CheckAsync) — rejects non-monotonicmanifestVersionunlessForceActivateis set with a non-emptyForceActivateReason— failure codesVERSION_PARSE_FAILED,VERSION_NON_MONOTONIC,FORCE_ACTIVATE_REASON_REQUIRED.
On success the validator records activation via VersionMonotonicityChecker.RecordActivationAsync and returns BundleValidationResult.Success("import-validated").
DSSE verification details
- Require trust roots (
TrustedKeyFingerprintsandPublicKeysnon-empty). - Enforce allowed signature algorithms (RSA-PSS/SHA256 only).
- Enforce the trust window (
NotBeforeUtc/NotAfterUtc). - Decode the base64 payload, build PAE (
DssePreAuthenticationEncoding.Encode). - Match each signature
keyidto a trusted public key whose SHA-256 fingerprint is allowlisted, then verify with the crypto provider (PS256).
manifest.json (canonical format)
Per docs/modules/airgap/guides/mirror-bundles.md, the staged bundle carries a manifest.json with:
bundleId,mirrorGeneration,createdAt,producer(export center)hashes(sha256 list) andmerkleRoot(read byImportValidator)dsseEnvelopeHashfor the signed manifest (when available)files[]:path,sha256,size,mediaType(sorted by path; canonical UTF-8 paths)manifestVersion(bumped on any schema change; checked for monotonicity)
Import / Catalog Flow
1. Stage bundle package (offline media / mirror)
2. Concelier aggregates it into the air-gap catalog
3. ImportValidator runs TUF → DSSE → Merkle → rotation → referrer → monotonicity
4. On success, register in the per-tenant bundle catalog (IAirgapImportStore)
5. Emit a timeline event (IBundleTimelineEmitter) recording actor, scope, and stats
6. Apply to the sealed environment
Registration & Query API
Mirror bundle registration / query (Excititor)
Served by MirrorRegistrationEndpoints under /airgap/v1/mirror/bundles; all routes require the tenant header (RequireTenant()) and the excititor.vex.read policy (scope vex:read).
GET /airgap/v1/mirror/bundles?publisher={p}&importedAfter={iso}&limit=50&offset=0
-> MirrorBundleListResponse { bundles[], totalCount, limit, offset, queriedAt }
GET /airgap/v1/mirror/bundles/{bundleId}
-> MirrorBundleDetailResponse { bundleId, mirrorGeneration, tenantId, publisher,
signedAt, importedAt, provenance{payloadHash, signature, payloadUrl,
transparencyLog, manifestHash}, staleness{...}, paths{portableManifestPath,
evidenceLockerPath}, timeline[], queriedAt }
GET /airgap/v1/mirror/bundles/{bundleId}/timeline
-> MirrorBundleTimelineResponse { bundleId, mirrorGeneration, timeline[], queriedAt }
MirrorBundleSummary fields: bundleId, mirrorGeneration, publisher, signedAt, importedAt, payloadHash, stalenessSeconds, status.
Mirror distribution (Excititor)
Served by MirrorEndpoints under /excititor/mirror:
GET /excititor/mirror/domains (anonymous)
GET /excititor/mirror/domains/{domainId} (anonymous)
GET /excititor/mirror/domains/{domainId}/index (excititor.vex.read; per-domain auth + rate limit)
GET /excititor/mirror/domains/{domainId}/exports/{exportKey} (excititor.vex.read)
GET /excititor/mirror/domains/{domainId}/exports/{exportKey}/download (excititor.vex.read; rate limited)
Air-gap catalog / sources / import (Concelier)
Served by AirGapEndpointExtensions under /api/v1/concelier/airgap (gated by ConcelierOptions.AirGap.Enabled; tenant header required):
GET /api/v1/concelier/airgap/catalog (Concelier.Advisories.Read)
GET /api/v1/concelier/airgap/sources (Concelier.Advisories.Read)
POST /api/v1/concelier/airgap/sources (Concelier.Advisories.Ingest)
GET /api/v1/concelier/airgap/sources/{sourceId} (Concelier.Advisories.Read)
DELETE /api/v1/concelier/airgap/sources/{sourceId} (Concelier.Advisories.Ingest)
POST /api/v1/concelier/airgap/sources/{sourceId}/validate (Concelier.Advisories.Ingest)
GET /api/v1/concelier/airgap/status (Concelier.Advisories.Read)
POST /api/v1/concelier/airgap/bundles/{bundleId}/import (Concelier.Advisories.Ingest)
body: { tenantId (required), scope, actorId, actorType, actorDisplayName, evidenceBundleRef }
-> BundleImportResponseDto { eventId, bundleId, tenantId, stats, occurredAt }
Note: the import handler currently emits a timeline event and import stats but the underlying ingestion is a placeholder (
// TODO: Wire actual bundle import logic hereinAirGapEndpointExtensions); stats are derived from catalog metadata, not a live ingest. TreatPOST .../importas partially implemented.
Seal / unseal / verify (AirGap Controller)
Served by AirGapEndpoints under /system/airgap (scope-asserted via AirGapPolicies):
GET /system/airgap/status (airgap:status:read)
POST /system/airgap/seal (airgap:seal)
POST /system/airgap/unseal (airgap:seal)
POST /system/airgap/verify (airgap:status:read)
NOT IMPLEMENTED / corrected: the previously documented Registration API (
POST /api/v1/airgap/bundleswith a{bundlePath, trustRootsPath}body returning{importId, status: "validating"}, andGET /api/v1/airgap/bundles/{bundleId}returning{bundleId, domainId, status, exportCount}) does not exist on any route. No endpoint acceptsbundlePath/trustRootsPathor returns animportId. The real surfaces are listed above.
Authorization Scopes
Verified against StellaOps.Auth.Abstractions/StellaOpsScopes.cs:
| Scope | Const | Used by |
|---|---|---|
airgap:seal | AirgapSeal | /system/airgap/seal, /unseal |
airgap:import | AirgapImport | offline bundle import authorization |
airgap:status:read | AirgapStatusRead | /system/airgap/status, /system/airgap/verify |
vex:read | VexRead (policy excititor.vex.read) | mirror bundle registration/query + distribution index/metadata/download |
There is no
airgap:verifyscope./system/airgap/verifyis gated by theAirGapPolicies.Verifypolicy, which assertsStellaOpsScopes.AirgapStatusRead(Program.cs:59-69) viaAirGapScopeAssertion— verification is a read-only integrity check, so it shares the air-gap read scope.
Determinism Guarantees
- Digest verification: all payload entries are hashed and folded into a Merkle root compared against
manifest.json. - Stable ordering:
files[]sorted by path;sourceProviderssorted; query signatures built from ordinally-sortedkey=valuepairs (VexQuerySignature.FromFilters). - Immutable content: bundle content is immutable once signed; re-imports of the same
bundleId/mirrorGenerationare rejected (AIRGAP_DUPLICATE_IMPORT). - Canonical JSON: mirror responses serialized via
VexCanonicalJsonSerializer. - Traceability: full provenance + timeline per bundle (
MirrorBundleProvenance,MirrorBundleTimelineEntry).
Error Codes
Sealed-mode / air-gap errors are mapped by AirgapErrorMapping.FromErrorCode (AirgapMirrorContracts.cs) to a structured AirgapErrorResponse (errorCode, message, category, retryable, details, remediation):
| Error code | Category | Retryable |
|---|---|---|
AIRGAP_EGRESS_BLOCKED | sealed_mode | no |
AIRGAP_SOURCE_UNTRUSTED | trust | no |
AIRGAP_SIGNATURE_MISSING | validation | no |
AIRGAP_SIGNATURE_INVALID | validation | no |
AIRGAP_PAYLOAD_STALE | validation | yes |
AIRGAP_PAYLOAD_MISMATCH | trust | no |
AIRGAP_DUPLICATE_IMPORT | duplicate | no |
AIRGAP_BUNDLE_NOT_FOUND | not_found | no |
Staleness categories (StalenessCalculator.CategorizeAge): fresh (<1h), recent (<1d), stale (<1w), old (<30d), very_old (≥30d).
Unblocks
This contract unblocks the following tasks:
- POLICY-AIRGAP-56-001
- POLICY-AIRGAP-56-002
- EXCITITOR-AIRGAP-56-001
- EXCITITOR-AIRGAP-58-001
- CLI-AIRGAP-56-001
- AIRGAP-TIME-57-001
Related Contracts
- Sealed Mode Contract - Sealed environment operation
- Verification Policy Contract - Attestation verification
- Export Bundle Contract - Export job scheduling
