Offline Sync Flow

Audience: operators and integrators running StellaOps in air-gapped or disconnected estates, and developers working on the AirGap modules (src/AirGap).

Overview

The Offline Sync Flow describes how StellaOps supports air-gapped and disconnected environments through the Offline Kit. This flow covers knowledge-bundle generation, secure transfer, verification, and import in environments with no external network connectivity.

Business Value: Enable full vulnerability scanning and policy evaluation capabilities in highly secure, air-gapped environments while maintaining audit trails and cryptographic verification.

Actors

Reconciled against src/AirGap (Controller, Importer, Time, __Libraries/StellaOps.AirGap.Bundle, __Libraries/StellaOps.AirGap.Sync) and the scope catalog src/Authority/.../StellaOps.Auth.Abstractions/StellaOpsScopes.cs. The original actor list attributed offline-bundle sealing/signing to EvidenceLocker and a standalone Signer service; that is INCORRECT. Offline knowledge bundles are built and DSSE-signed inside the AirGap.Bundle library (SnapshotBundleWriter + SnapshotManifestSigner), not by EvidenceLocker (which seals decision capsules, a separate concern — see src/EvidenceLocker/.../Api/Capsules).

ActorTypeRole
Online AdminHumanGenerates and exports offline knowledge bundles (stella airgap export)
Offline AdminHumanImports and verifies bundles (stella airgap import [--verify-only])
Concelier (mirror)ServiceSource of advisory raw data snapshotted into bundles (StellaOps.Concelier.MirrorSeedTool)
AirGap.Bundle (SnapshotBundleWriter)LibraryAssembles the snapshot, computes the Merkle root, writes manifest.json, and DSSE-signs manifest.sig
AirGap ControllerServiceSeal/unseal state machine + status/verify APIs under /system/airgap
AirGap ImporterLibraryValidates bundles (TUF → DSSE → Merkle → rotation → referrer → version monotonicity) and quarantines failures
AirGap TimeServiceTime-anchor ingest/status (/api/v1/time); Roughtime + RFC3161 verification; staleness budgets
Import targetsAdaptersLoad verified content into Concelier (advisories), Excititor (VEX), and the Policy registry

Prerequisites

Online Environment

Offline Environment

Authority Scopes

Source of truth: src/Authority/.../StellaOps.Auth.Abstractions/StellaOpsScopes.cs.

ScopeConstantUsed by
airgap:status:readAirgapStatusReadGET /system/airgap/status, GET /api/v1/time/status
airgap:sealAirgapSealPOST /system/airgap/seal, POST /system/airgap/unseal, POST /api/v1/time/anchor (write)
airgap:importAirgapImportOffline bundle import while sealed

The time-anchor write endpoint (POST /api/v1/time/anchor) is guarded by the ASP.NET policy AirGapTime.AnchorWrite, whose backing scope is airgap:seal (AirGapTimePolicies.AnchorWriteScope = StellaOpsScopes.AirgapSeal) — there is no distinct airgap:anchor:write scope.

NOTE: POST /system/airgap/verify is registered against the AirGapPolicies.Verify policy, whose backing scope is airgap:status:read (StellaOpsScopes.AirgapStatusRead, Program.cs:59-69) — verification is a read-only integrity check, so it shares the air-gap read scope. There is no airgap:verify scope; only the three scopes above are catalogued. (airgap:write still appears as an expected import scope in AirGapControllerContractTests:224 and is absent from the catalog — that one remains a code/catalog gap.)

Bundle Types

In practice these are sections of one knowledge snapshot bundle (KnowledgeSnapshotManifest carries advisories, vexStatements, policies, ruleBundles, trustRoots, and an optional timeAnchor), rather than five independently scheduled artifacts. The “Frequency” column is operator convention, not an enforced schedule.

SectionMaps toManifest field
Advisoriesper-feed advisory files (advisories/<feedId>/)advisories (AdvisorySnapshotEntry)
VEXper-source VEX files (vex/<sourceId>/)vexStatements (VexSnapshotEntry)
Policiespolicy files (policies/)policies (PolicySnapshotEntry)
Rule bundlesrule files (rules/<bundleId>)ruleBundles (RuleBundleSnapshotEntry)
Trust rootsDSSE trust material (trust/)trustRoots (TrustRootSnapshotEntry)
Time anchorRoughtime or RFC3161 tokentimeAnchor (TimeAnchorEntry)

The original “Time Anchor … NTP alternatives” entry is corrected: implemented anchor formats are Roughtime and RFC3161 only (TimeTokenFormat); plain NTP is not a supported anchor source.

Flow Diagram

NOTE: the diagram is conceptual. The “Evidence Locker” / “Sign” / “Verif” boxes predate reconciliation — in the implemented system the online side is SnapshotBundleWriter + SnapshotManifestSigner (DSSE), and the offline side is the AirGap Controller (seal/verify) plus the Importer validation chain. See the Actors table and step-by-step for accurate component names.

┌─────────────────────────────────────────────────────────────────────────────────┐
│                            Offline Sync Flow                                     │
└─────────────────────────────────────────────────────────────────────────────────┘

 ONLINE ENVIRONMENT                         OFFLINE ENVIRONMENT
┌────────────────────────────────┐         ┌────────────────────────────────┐
│                                │         │                                │
│ ┌────────┐  ┌────────┐  ┌────┐│         │┌────┐  ┌─────────┐  ┌────────┐│
│ │ Mirror │  │Evidence│  │Sign││         ││Verif│  │ AirGap  │  │Concelier││
│ │        │  │ Locker │  │    ││         ││     │  │Importer │  │        ││
│ └───┬────┘  └───┬────┘  └──┬─┘│         │└──┬──┘  └────┬────┘  └───┬────┘│
│     │           │          │  │         │   │          │           │     │
│     │ Snapshot  │          │  │         │   │          │           │     │
│     │ advisories│          │  │         │   │          │           │     │
│     │───┐       │          │  │         │   │          │           │     │
│     │   │       │          │  │         │   │          │           │     │
│     │<──┘       │          │  │         │   │          │           │     │
│     │           │          │  │         │   │          │           │     │
│     │ Create    │          │  │         │   │          │           │     │
│     │ bundle    │          │  │         │   │          │           │     │
│     │──────────>│          │  │         │   │          │           │     │
│     │           │          │  │         │   │          │           │     │
│     │           │ Seal     │  │         │   │          │           │     │
│     │           │──────────>  │         │   │          │           │     │
│     │           │          │  │         │   │          │           │     │
│     │           │ Signed   │  │         │   │          │           │     │
│     │           │ bundle   │  │         │   │          │           │     │
│     │           │<──────────  │         │   │          │           │     │
│     │           │          │  │         │   │          │           │     │
│     │           │          │  │         │   │          │           │     │
│     │      [Export to       ] │         │   │          │           │     │
│     │      [removable media ] │ =========> [Import from│           │     │
│     │           │          │  │         │   │removable]│           │     │
│     │           │          │  │         │   │          │           │     │
│     │           │          │  │         │   │ Verify   │           │     │
│     │           │          │  │         │   │ signature│           │     │
│     │           │          │  │         │   │───┐      │           │     │
│     │           │          │  │         │   │   │      │           │     │
│     │           │          │  │         │   │<──┘      │           │     │
│     │           │          │  │         │   │          │           │     │
│     │           │          │  │         │   │ Verify   │           │     │
│     │           │          │  │         │   │ Merkle   │           │     │
│     │           │          │  │         │   │───┐      │           │     │
│     │           │          │  │         │   │   │      │           │     │
│     │           │          │  │         │   │<──┘      │           │     │
│     │           │          │  │         │   │          │           │     │
│     │           │          │  │         │   │ Import   │           │     │
│     │           │          │  │         │   │──────────>           │     │
│     │           │          │  │         │   │          │           │     │
│     │           │          │  │         │   │          │ Unpack    │     │
│     │           │          │  │         │   │          │ advisories│     │
│     │           │          │  │         │   │          │───┐       │     │
│     │           │          │  │         │   │          │   │       │     │
│     │           │          │  │         │   │          │<──┘       │     │
│     │           │          │  │         │   │          │           │     │
│     │           │          │  │         │   │          │ Merge to  │     │
│     │           │          │  │         │   │          │ Concelier │     │
│     │           │          │  │         │   │          │──────────>│     │
│     │           │          │  │         │   │          │           │     │
│     │           │          │  │         │   │          │           │     │
└────────────────────────────────┘         └────────────────────────────────┘

Step-by-Step

1. Advisory Snapshot (Online)

Source advisory data comes from Concelier’s raw advisory store (mirror seeding via StellaOps.Concelier.MirrorSeedTool). The illustrative snapshot_id / delta_from envelope below is conceptual — there is no snapshot.json of this shape in src/AirGap; per-feed snapshots are captured into the manifest as AdvisorySnapshotEntry rows (feedId, relativePath, digest, recordCount, snapshotAt) when SnapshotBundleWriter builds the bundle.

{
  "snapshot_id": "snap-20241229",
  "created_at": "2024-12-29T00:00:00Z",
  "sources": [
    {"name": "nvd", "last_sync": "2024-12-29T00:00:00Z", "count": 245678},
    {"name": "ghsa", "last_sync": "2024-12-28T23:45:00Z", "count": 45678},
    {"name": "osv", "last_sync": "2024-12-28T23:30:00Z", "count": 89012}
  ],
  "delta_from": "snap-20241228",
  "advisories": {
    "new": 127,
    "updated": 456,
    "unchanged": 245095
  }
}

2. Bundle Generation (Online)

SnapshotBundleWriter (in StellaOps.AirGap.Bundle) assembles the snapshot and writes a manifest.json (KnowledgeSnapshotManifest). The manifest below reflects the actual schema — the original example invented an integrity/contents block that does not exist in code:

{
  "bundleId": "knowledge-2024-12-29",
  "name": "knowledge-2024-12-29",
  "version": "1.0.0",
  "createdAt": "2024-12-29T01:00:00Z",
  "schemaVersion": "1.0.0",
  "merkleRoot": "sha256:abc123...",
  "totalSizeBytes": 131072000,
  "entryCount": 15,
  "advisories": [
    { "feedId": "nvd", "relativePath": "advisories/nvd/nvd.ndjson", "digest": "sha256:...", "sizeBytes": 0, "recordCount": 245678 }
  ],
  "vexStatements": [],
  "policies": [],
  "trustRoots": [],
  "ruleBundles": [],
  "timeAnchor": null
}

The Merkle root is computed over the per-entry digests (ComputeMerkleRoot) and stored inside the manifest — there is no separate merkle/tree.json artifact. The archive is a deterministic PAX tar.gz (TarEntryFormat.Pax, written by SnapshotBundleWriter.Tar.cs) with a fixed modification time and ordinal path ordering. The on-disk extension is .tar.gz (NormalizeOutputPath appends .tar.gz if missing); the CLI default output name is knowledge-<yyyyMMdd>.tar.gz. There is no .stella.bundle.tgz extension in the code.

3. Bundle Signing (Online)

SnapshotManifestSigner produces a DSSE envelope over the canonical manifest bytes and writes it to manifest.sig. This corrects the original “detached PKCS#7 signature + full cert chain + external TSA” model, which is not what the code does:

{
  "payloadType": "application/vnd.stellaops.knowledge-snapshot+json",
  "payload": "base64(manifest.json)...",
  "signatures": [
    { "keyid": "<sha256 fingerprint of public key>", "sig": "base64(signature)..." }
  ]
}

4. Export Package

Final package layout reflects the directories actually created by SnapshotBundleWriter (Sections.* partials). The original tree listed a merkle/tree.json, verify.sh, and README.md that the writer does not emit:

knowledge-<yyyyMMdd>.tar.gz       (deterministic PAX tar.gz)
├── manifest.json                 # KnowledgeSnapshotManifest (includes merkleRoot, entries)
├── manifest.sig                  # DSSE envelope over the manifest (present only when signed)
├── advisories/
│   └── <feedId>/<fileName>       # e.g. advisories/nvd/nvd.ndjson
├── vex/
│   └── <sourceId>/<fileName>
├── policies/
│   └── <fileName>
├── rules/
│   └── <ruleBundleId>/<fileName> # rule files live under the bundle dir
└── trust/
    └── <fileName>                # trust-root material referenced by the manifest

A timeAnchor entry, when present, is recorded in manifest.json (TimeAnchorEntry) rather than as a fixed top-level directory.

5. Secure Transfer

Transfer via approved mechanism:

6. Import Verification (Offline)

The CLI binary is stella (the airgap command group lives in AirGapCommandGroup.cs); there is no stellaops-airgap binary. The airgap group exposes export, import, diff, status, and a jobs subgroup (export/import/list) — there is no airgap verify-bundle subcommand. Verification-only is the import command with --verify-only, and import takes the bundle as a positional argument, not --bundle. Verification runs through ImportValidator.ValidateAsync, which executes a fixed ordered chain and quarantines the bundle on any failure (under /updates/quarantine/<tenantId>/...):

# Verify a bundle without applying changes (positional bundle arg)
stella airgap import /mnt/media/knowledge-20241229.tar.gz --verify-only

# Import (verifies, then activates) — see step 8
stella airgap import /mnt/media/knowledge-20241229.tar.gz

A separate stella verify bundle --bundle <path> command exists (VerifyCommandGroup.cs), but it verifies E2E evidence bundles (input-hash + verdict replay), not knowledge-snapshot bundles — do not confuse the two.

ImportValidator chain (each step fails closed; reason codes are emitted as structured logs):

  1. TUF metadataTufMetadataValidator.Validate(root, snapshot, timestamp) (TUF_INVALID).
  2. DSSE signatureDsseVerifier.Verify against trusted fingerprints/keys + trust window (DSSE_INVALID).
  3. Merkle rootMerkleRootCalculator.ComputeRoot over payload entries must equal the manifest’s merkleRoot (MERKLE_ROOT_MISSING / MERKLE_ROOT_MISMATCH).
  4. Root rotation policyRootRotationPolicy.Validate over active/pending keys + approver IDs (ROTATION_INVALID).
  5. Referrer validation — only for mirror-bundle / offline-kit types (REFERRER_VALIDATION_FAILED; counts missing/checksum/size mismatches).
  6. Version monotonicityIVersionMonotonicityChecker blocks rollback per tenant/type (VERSION_NON_MONOTONIC); overridable only with --force and a forceActivateReason.

The original example showed an X.509 certificate-chain check and a TSA timestamp window. Neither matches the code: trust is fingerprint-based DSSE, and freshness is enforced separately by the Time service’s staleness budgets (see step 7), not as part of bundle signature verification.

7. Time Anchor Verification (Offline)

The AirGap Time service (/api/v1/time) ingests a time anchor and reports staleness without network time. Anchors are Roughtime or RFC3161 tokens (TimeTokenFormat), verified by RoughtimeVerifier / Rfc3161Verifier. Freshness is expressed as a staleness budget (StalenessBudget: warningSeconds, breachSeconds; default 3600 / 7200), evaluated into a StalenessEvaluation (isWarning / isBreach) — there is no FRESH/STALE/EXPIRED enum.

# Read status requires airgap:status:read; write (set anchor) requires airgap:seal
# (policy AirGapTime.AnchorWrite → scope airgap:seal). The body maps to SetAnchorRequest;
# trustRootKeyId / trustRootAlgorithm / trustRootPublicKeyBase64 are optional and only
# honoured outside Production (AllowRequestSuppliedTrustRoots).
POST /api/v1/time/anchor
{ "tenantId": "...", "hexToken": "<roughtime|rfc3161 token hex>", "format": "Roughtime",
  "warningSeconds": 3600, "breachSeconds": 7200 }

GET /api/v1/time/status returns a TimeStatusDto. The actual shape is wider than the staleness evaluation alone — it embeds anchor identity plus per-content staleness. Note secondsRemaining is a computed property on StalenessEvaluation, not a field of TimeStatusDto:

{
  "anchorTime": "2024-12-28T16:00:00.0000000Z",
  "format": "Roughtime",
  "source": "roughtime:<server>",
  "fingerprint": "<signing key fingerprint>",
  "digest": "<token digest>",
  "ageSeconds": 32400,
  "warningSeconds": 3600,
  "breachSeconds": 7200,
  "isWarning": true,
  "isBreach": true,
  "contentStaleness": { "advisories": { "ageSeconds": 32400, "warningSeconds": 3600, "breachSeconds": 7200, "isWarning": true, "isBreach": true } },
  "evaluatedAtUtc": "2024-12-29T01:00:00.0000000Z"
}

The seal flow (POST /system/airgap/seal) records a driftBaselineSeconds (wall clock vs. anchor at seal) and per-content staleness budgets (advisories / vex / policy). Request-supplied trust roots are rejected outside Development/Testing (AllowRequestSuppliedTrustRoots).

8. Advisory Import (Offline)

KnowledgeSnapshotImporter loads verified content into the registered import targets:

The result shape is SnapshotImportResult. The per-module processed/created/updated/failed counts live in a nested Statistics object (ImportStatistics), not flat at the root; the root carries success, bundleId, startedAt, completedAt, errors, and error. ImportStatistics also exposes computed totalProcessed/totalCreated/totalUpdated/totalFailed rollups. The original example’s import_id / advisories_imported / snapshot-state fields are not the actual contract:

{
  "success": true,
  "bundleId": "knowledge-2024-12-29",
  "startedAt": "2024-12-29T02:00:00Z",
  "completedAt": "2024-12-29T02:00:42Z",
  "statistics": {
    "advisoriesProcessed": 583,
    "advisoriesCreated": 127,
    "advisoriesUpdated": 456,
    "advisoriesFailed": 0,
    "vexProcessed": 0,
    "vexCreated": 0,
    "vexUpdated": 0,
    "vexFailed": 0,
    "policiesProcessed": 0,
    "policiesCreated": 0,
    "policiesUpdated": 0,
    "policiesFailed": 0
  },
  "errors": [],
  "error": null
}

Bundle Freshness Policies

DRAFT / illustrative. The YAML profiles below are an operator-facing convention and do not map to a config schema in src/AirGap. The implemented freshness control is the per-tenant StalenessBudget (warningSeconds / breachSeconds, default 3600 / 7200) set on the time anchor and at seal time, plus optional per-content budgets (advisories / vex / policy). Treat the mode / max_age / require_tsa keys as roadmap, not current behavior.

Strict (High Security)

freshness_policy:
  mode: strict
  max_age:
    advisory: 24h
    vex: 24h
    policy: 7d
    trust: 30d
  require_tsa: true
  reject_stale: true

Standard

freshness_policy:
  mode: standard
  max_age:
    advisory: 7d
    vex: 7d
    policy: 30d
    trust: 90d
  require_tsa: false
  warn_stale: true

Permissive (Disconnected Operations)

freshness_policy:
  mode: permissive
  max_age:
    advisory: 30d
    vex: 30d
    policy: 90d
    trust: 365d
  require_tsa: false
  warn_stale: true
  allow_manual_override: true

Data Contracts

The TypeScript interfaces below are an illustrative/aspirational view. The authoritative schemas live in C# under StellaOps.AirGap.Bundle.Models: the on-disk knowledge manifest is KnowledgeSnapshotManifest (see step 2 — bundleId/name/version/merkleRoot/entryCount plus typed entry lists), and import results are SnapshotImportResult (step 8). Field names below use snake_case and a flatter OfflineBundle shape that does not match the implemented camelCase records; retained here as a conceptual summary, flagged as not-implemented-as-written.

Bundle Manifest Schema (conceptual — see KnowledgeSnapshotManifest for the real schema)

interface OfflineBundle {
  bundle_id: string;
  bundle_type: 'advisory' | 'vex' | 'policy' | 'trust' | 'time_anchor';
  version: string;
  created_at: string;
  created_by: string;
  contents: {
    files: Array<{
      path: string;
      size: number;
      sha256: string;
    }>;
    metadata: Record<string, unknown>;
  };
  integrity: {
    merkle_root: string;
    algorithm: 'sha256';
    tree_path: string;
  };
  signature: {
    keyid: string;
    algorithm: string;
    sig: string;
  };
  freshness: {
    timestamp: string;
    tsa_timestamp?: string;
    valid_until?: string;
  };
}

Import Result Schema (conceptual — see SnapshotImportResult for the real schema)

interface ImportResult {
  import_id: string;
  bundle_id: string;
  status: 'success' | 'partial' | 'failed';
  started_at: string;
  completed_at: string;
  results: {
    records_imported: number;
    records_updated: number;
    records_new: number;
    conflicts: number;
    errors: number;
  };
  verification: {
    signature_valid: boolean;
    merkle_verified: boolean;
    freshness_check: 'FRESH' | 'STALE' | 'EXPIRED';
  };
  audit_log_entry: string;
}

Scheduling Strategies

DRAFT / illustrative. The offline_sync: YAML below is an operator convention; there is no such config schema in src/AirGap. Bundle export is invoked on demand via stella airgap export (and can be wrapped in any external scheduler/cron). type: delta vs. full and retention are roadmap conventions, not implemented config keys.

Daily Sync

offline_sync:
  advisory_bundle:
    schedule: "0 1 * * *"  # Daily at 1 AM
    type: delta
    retention: 7
  vex_bundle:
    schedule: "0 2 * * *"  # Daily at 2 AM
    type: delta
    retention: 7

Weekly Full + Daily Delta

offline_sync:
  advisory_bundle:
    full:
      schedule: "0 0 * * SUN"  # Weekly full on Sunday
      retention: 4
    delta:
      schedule: "0 1 * * MON-SAT"  # Daily delta Mon-Sat
      retention: 7

Error Handling

Reconciled to ImportValidator reason codes. Any failure in the validation chain is logged with a reason_code and the bundle is moved to quarantine (/updates/quarantine/<tenantId>/...) for triage rather than partially imported.

FailureReason codeBehavior
TUF metadata invalidTUF_INVALIDReject + quarantine
DSSE signature untrusted/invalidDSSE_INVALIDReject + quarantine
Computed Merkle root emptyHASH_MISMATCH (reason merkle-empty)Reject + quarantine
Merkle root missing/mismatchMERKLE_ROOT_MISSING / MERKLE_ROOT_MISMATCHReject + quarantine
Trust-root rotation invalidROTATION_INVALIDReject + quarantine
Referrer validation failedREFERRER_VALIDATION_FAILEDReject + quarantine (mirror/offline-kit types)
Version rollback (non-monotonic)VERSION_NON_MONOTONICReject unless ForceActivate + ForceActivateReason
Force activate without reasonFORCE_ACTIVATE_REASON_REQUIREDReject + quarantine
Manifest version unparseableVERSION_PARSE_FAILEDReject + quarantine
Version store write failed(reason version-store-write-failed:*)Reject + quarantine
Time anchor over budget(Time service)isWarning / isBreach on staleness evaluation

Observability

Metric names reconciled against AirGapTelemetry (Controller meter StellaOps.AirGap.Controller) and OfflineKitMetrics (Importer meter StellaOps.AirGap.Importer). The original offline_* metric names were invented and have been replaced with the emitted instruments.

Metrics — Controller (StellaOps.AirGap.Controller)

MetricTypeLabels / Tags
airgap_seal_totalCountertenant, sealed
airgap_unseal_totalCountertenant, sealed
airgap_startup_blocked_totalCountertenant, reason
airgap_time_anchor_age_secondsObservableGaugetenant
airgap_staleness_budget_secondsObservableGaugetenant

Metrics — Importer (StellaOps.AirGap.Importer)

MetricTypeLabels / Tags
offlinekit_import_totalCounterstatus, tenant_id
offlinekit_attestation_verify_latency_secondsHistogramattestation_type, success
attestor_rekor_success_totalCountermode
attestor_rekor_retry_totalCounterreason
rekor_inclusion_latencyHistogramsuccess

Key Log Events

Structured log message templates as emitted in code (see AirGapTelemetry / ImportValidator).

EventLevelFields
airgap.sealedINFOtenant, policy_hash, anchor_source, anchor_digest, age_seconds
airgap.unsealedINFOtenant, last_transition_at
airgap.startup.validation (passed/failed)INFO / CRITICALtenant, allowlist_count / reason
offlinekit.import.validation (succeeded/failed)INFO / WARNtenant_id, bundle_type, bundle_digest, reason_code
offlinekit.import.force_activationWARNtenant_id, bundle_type, incoming_version, current_version, force_activate_reason
offlinekit.import.quarantineERRORtenant_id, bundle_path, reason_code