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:

Implementation References

NOT IMPLEMENTED / FLAGGED: the previously referenced JSON schema docs/modules/airgap/schemas/mirror-bundle.schema.json does not exist in the repository. The docs/modules/airgap/schemas/ directory contains manifest.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), and src/Mirror/StellaOps.Mirror.Creator/ is a separate mirror-sync planner (MirrorSyncPlan/MirrorSyncResult in MirrorModels.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": [
    { ... }
  ]
}
FieldTypeSource fieldDescription
idstringMirrorDomainIndex.IdDomain identifier (operator-configured)
displayNamestringMirrorDomainIndex.DisplayNameHuman-readable name (falls back to id)
generatedAtdatetimeMirrorDomainIndex.GeneratedAtISO-8601 generation timestamp
exportsarrayMirrorDomainIndex.ExportsExport index entries (see below)

NOT IMPLEMENTED: the originally documented top-level object with schemaVersion, targetRepository, and a domainId field on the index does not exist. A separate MirrorBundleDocument (the StellaOps-mirror connector bundle, not the export index) does carry schemaVersion, generatedAt, targetRepository, domainId, displayName, advisoryCount, advisories[], and sources[] — 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
}
FieldTypeSource symbolDescription
exportKeystringMirrorExportIndexEntry.ExportKeyOperator-defined export key within the domain
exportIdstringVexExportManifest.ExportIdUnique export identifier
querySignaturestringVexExportManifest.QuerySignature (VexQuerySignature.Value)Deterministic key=value&... filter signature
formatstringVexExportManifest.FormatExport format, lower-cased (see Export Formats)
createdAtdatetimeVexExportManifest.CreatedAtExport creation timestamp
artifactobjectVexExportManifest.Artifact (VexContentAddress)Content address with algorithm + digest
sizeBytesintegerVexExportManifest.SizeBytesArtifact size in bytes
sourceProvidersarrayVexExportManifest.SourceProvidersContributing providers (sorted) — present on MirrorExportMetadata, omitted from the slim index entry
consensusRevisionstring?VexExportManifest.ConsensusRevisionOptional consensus revision id
attestationobject?VexExportManifest.Attestation (MirrorExportAttestation)Optional attestation descriptor
statusstring?MirrorExportIndexEntry.StatusNon-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): artifactSizeBytessizeBytes; artifactDigest (a sha256:... string) → artifact (a {algorithm, digest} content address); the key field → exportKey. policyRevisionId, policyDigest, consensusDigest, and scoreDigest live on VexExportManifest but are not emitted on the mirror endpoint DTOs.

Export Formats (VexExportFormat)

Format valueEnum memberDescription
jsonVexExportFormat.JsonStandard JSON
jsonlVexExportFormat.JsonLinesNewline-delimited JSON
openvexVexExportFormat.OpenVexOpenVEX format
csafVexExportFormat.CsafCSAF VEX format
cyclonedxVexExportFormat.CycloneDxCycloneDX VEX format

Corrected: the enum is defined in VexExportManifest.cs with exactly these five members. The previously listed spdx format does not exist, and ndjson is spelled jsonl (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 BundleSignature object with path, algorithm: ES256, keyId, provider, signedAt does not match any verification path. DsseVerifier.IsAlgorithmAllowed accepts only PS256 / RSASSA-PSS-SHA256 / RSA-PSS-SHA256; ES256 is rejected. ES256 appears only as an example default string in the MirrorSigningOptions.Algorithm doc-comment (publisher side) and is not the enforced verification algorithm. The DSSE envelope itself uses keyid on each signature (DsseEnvelope); trust is established via TrustRootConfig.TrustedKeyFingerprints + PublicKeys.

Mirror Domains

Domains are operator-configured under the Excititor:Mirror configuration section (MirrorDistributionOptions.SectionName), not a fixed enumeration. Each MirrorDomainOptions defines:

Config keyTypeDefaultDescription
IdstringDomain identifier
DisplayNamestringIdHuman-readable name
RequireAuthenticationboolfalseWhen true, index/metadata/download require an authenticated principal
MaxIndexRequestsPerHourint120Per-domain index rate limit
MaxDownloadRequestsPerHourint600Per-domain download rate limit
Exports[]arrayMirrorExportOptions (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)

  1. TUF metadata (TufMetadataValidator.Validate over RootJson / SnapshotJson / TimestampJson) — failure code TUF_INVALID.
  2. DSSE signature (DsseVerifier.Verify) — RSA-PSS/SHA256, trusted-fingerprint check, allowed-algorithm check, trust-window check; failure code DSSE_INVALID.
  3. Merkle root (MerkleRootCalculator.ComputeRoot over PayloadEntries) compared against the merkleRoot / merkle_root field in manifest.json — failure codes HASH_MISMATCH, MERKLE_ROOT_MISSING, MERKLE_ROOT_MISMATCH.
  4. Trust-root rotation (RootRotationPolicy.Validate over active/pending keys + approver IDs) — failure code ROTATION_INVALID.
  5. Referrer validation (ReferrerValidator.Validate) — only for bundle types mirror-bundle and offline-kit; tracks missing/checksum/size mismatches and orphaned referrers — failure code REFERRER_VALIDATION_FAILED.
  6. Version monotonicity (VersionMonotonicityChecker.CheckAsync) — rejects non-monotonic manifestVersion unless ForceActivate is set with a non-empty ForceActivateReason — failure codes VERSION_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

  1. Require trust roots (TrustedKeyFingerprints and PublicKeys non-empty).
  2. Enforce allowed signature algorithms (RSA-PSS/SHA256 only).
  3. Enforce the trust window (NotBeforeUtc / NotAfterUtc).
  4. Decode the base64 payload, build PAE (DssePreAuthenticationEncoding.Encode).
  5. Match each signature keyid to 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:

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 here in AirGapEndpointExtensions); stats are derived from catalog metadata, not a live ingest. Treat POST .../import as 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/bundles with a {bundlePath, trustRootsPath} body returning {importId, status: "validating"}, and GET /api/v1/airgap/bundles/{bundleId} returning {bundleId, domainId, status, exportCount}) does not exist on any route. No endpoint accepts bundlePath/trustRootsPath or returns an importId. The real surfaces are listed above.

Authorization Scopes

Verified against StellaOps.Auth.Abstractions/StellaOpsScopes.cs:

ScopeConstUsed by
airgap:sealAirgapSeal/system/airgap/seal, /unseal
airgap:importAirgapImportoffline bundle import authorization
airgap:status:readAirgapStatusRead/system/airgap/status, /system/airgap/verify
vex:readVexRead (policy excititor.vex.read)mirror bundle registration/query + distribution index/metadata/download

There is no airgap:verify scope. /system/airgap/verify is gated by the AirGapPolicies.Verify policy, which asserts StellaOpsScopes.AirgapStatusRead (Program.cs:59-69) via AirGapScopeAssertion — verification is a read-only integrity check, so it shares the air-gap read scope.

Determinism Guarantees

  1. Digest verification: all payload entries are hashed and folded into a Merkle root compared against manifest.json.
  2. Stable ordering: files[] sorted by path; sourceProviders sorted; query signatures built from ordinally-sorted key=value pairs (VexQuerySignature.FromFilters).
  3. Immutable content: bundle content is immutable once signed; re-imports of the same bundleId/mirrorGeneration are rejected (AIRGAP_DUPLICATE_IMPORT).
  4. Canonical JSON: mirror responses serialized via VexCanonicalJsonSerializer.
  5. 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 codeCategoryRetryable
AIRGAP_EGRESS_BLOCKEDsealed_modeno
AIRGAP_SOURCE_UNTRUSTEDtrustno
AIRGAP_SIGNATURE_MISSINGvalidationno
AIRGAP_SIGNATURE_INVALIDvalidationno
AIRGAP_PAYLOAD_STALEvalidationyes
AIRGAP_PAYLOAD_MISMATCHtrustno
AIRGAP_DUPLICATE_IMPORTduplicateno
AIRGAP_BUNDLE_NOT_FOUNDnot_foundno

Staleness categories (StalenessCalculator.CategorizeAge): fresh (<1h), recent (<1d), stale (<1w), old (<30d), very_old (≥30d).

Unblocks

This contract unblocks the following tasks: