Airgapped Updates for Greentic

Design plan · rev 2, 2026-07-28 · code exploration verified 2026-07-24 (greentic-update / greentic-start / greentic-deployer, develop)

VERDICT: proposed approach is sound Deliverable: design document first Tier 1 sneakernet ships before in-gap server Static-file serving first, turnkey server later Rev 2: adversarial gate round 1 — 4 findings confirmed & folded in

TL;DR

The proposal — a customer-run updater server inside the airgap plus export/import tooling — is exactly the industry pattern (Replicated, Zarf/Hauler, TUF offline mode). Better: Greentic's updater was designed for this from day one. greentic-update/src/envelope.rs is an unimplemented stub whose doc-comment already specifies the whole design ("a signed wrapper carrying a plan plus its referenced artifacts for transfer across an air gap, and the import-side scanner… the airgapped and connected paths converge on the same staging FSM"), and the plan schema's artifact source fields are Option<String>, documented as "absent when carried in-band by an airgap envelope". We are finishing a planned feature, not bolting on a foreign one.

One refinement over the original proposal: the in-gap server must not be a hard prerequisite. A serverless sneakernet path (export → removable media → op updates import → apply) is a strict subset of the server story, covers single-host installs, and ships first. The fleet-scale in-gap server layers on top, reusing the same envelope file unchanged.

Problem & context

Greentic's update train is live and connected-only: DSSE-signed update plans are published to updates.greentic.cloud, runtimes poll (or receive SSE hints), verify plans against a per-environment local trust-root.json, and apply them through a staging state machine with snapshots and rollback — including binary self-update. Every step of delivery assumes outbound internet.

Enterprise and defense customers run airgapped environments: no outbound network, ever. They still need signed, verifiable, rollback-safe updates. The question assessed here: is "customer boots their own updater server inside the gap + import/export tooling" the right architecture, per industry best practice — or does something better exist?

Industry best practices

SystemAirgap modelWhat we borrow
Replicated (closest commercial analog) Vendor exports a signed .airgap bundle; customer uploads it to an on-prem admin console (or a watched directory); console verifies, then serves updates locally. One self-contained signed archive; verify-on-import; the on-prem console = our in-gap plan server.
Zarf / Hauler (CNCF airgap tooling) Declarative package built on the connected side containing ALL artifacts; single archive crosses via sneakernet; an in-gap registry/fileserver serves content. Artifacts travel with the metadata; in-gap serving is a plain registry/fileserver, not a special protocol.
TUF (security gold standard) Accepted airgap answer: manual transfer of signed metadata + targets; trust anchors provisioned offline; mirrors are client configuration, never signed content. End-to-end signatures survive the gap; offline trust roots; anti-rollback; M-of-N root signing (future); mirror-as-client-config.

Distilled principles

  • One self-contained signed archive for export/import — never loose files.
  • End-to-end verification inside the gap — the USB stick / diode is never trusted; only signatures and digests are.
  • Everything digest-pinned — the signed plan pins the sha256 of every artifact; the export carries the exact pinned bytes.
  • Offline trust anchor — trust roots provisioned at install time from files; no network trust resolution at runtime.
  • Anti-rollback + explicit freshness policy — monotonicity must survive imports; expiry must not brick long-offline environments.
  • Unmodified client protocol — in-gap clients speak the same protocol to the in-gap server; no "airgap mode" fork.

What exists today (verified fact base)

Everything below was verified in code on 2026-07-24. The striking result: verification is already transport-independent, and the offline halves of the pipeline already exist.

MechanismStatusWhere
Trust anchor is the local trust-root.json; fail-closed when missing; did:web NOT resolved on the client update pathEXISTSgreentic-deployer/src/environment/trust_root.rs
Fully offline trust CLI from PEM files: op trust-root bootstrap|add|list|removeEXISTSgreentic-deployer/src/cli/dispatch.rs:431
Anti-rollback: client-side monotonic sequence guard — survives imports naturallyEXISTSgreentic-update/src/plan.rs:419 (ensure_not_downgrade)
Apply is fully offline once blobs are staged: materialize_bundles() rewrites refs to content-addressed staged blobs; digests re-verified at staging AND applyEXISTSgreentic-deployer/src/cli/updates.rs:1636
Offline plan producer: op updates plan-build writes plan.json + plan.json.sigEXISTSupdates.rs:2183
Offline plan consumer: op updates get --plan-file --plan-sig-file ("airgap import / local testing")PARTIALupdates.rs:90-95 — plan only; artifacts still network-fetched
.gtbundle: self-contained signed SquashFS, transport-independent; bundle_path applies with zero network; secrets redacted at build timeEXISTSgreentic-bundle/src/build/export.rs, env_apply.rs:898
OCI fetcher offline cache-only mode + with_insecure_registries ("in-cluster / air-gapped registries")NO CLI SURFACEgreentic-distributor-client/src/oci_packs.rs:206,612bundle_fetch.rs hard-codes offline: false
Airgap envelope module — the design is fully specified in its doc-comment ("Implemented in Phase 5")STUBgreentic-update/src/envelope.rs
In-band artifact hooks: PlanArtifact.source / BinaryArtifact.source are Option<String> — "absent when carried in-band by an airgap envelope"SCHEMA ONLYgreentic-update/src/plan.rs:99-102, 125-128
Deny-by-default: no update-channel.json → no phoning home; updates config can never ride inside a signed planEXISTSdesign invariant, enforced at sign + apply

The plan-server wire contract is deliberately tiny: anonymous GET plan | plan.sig | plan/meta + optional SSE; authenticated POST …/plan with monotonic-sequence 409; GET /v1/environments for fleet listing. Production runs on a Cloudflare Worker (not airgappable); a private in-memory Rust server and a public wire-compatible demo Worker also exist.

Gaps to close

  1. envelope.rs is unimplemented — the pack/unpack/scan library does not exist.
  2. In-band artifacts are rejected by every consumer: DistArtifactFetcher hard-errors on source=None ("in-band airgap artifacts are not supported by op updates get", updates.rs:1763-1769); the runtime binary self-update skips source=None ("out of P7d scope", greentic-start/src/revision_serve.rs:2073).
  3. No export/import verbs, no in-gap serving story, no CLI surface for offline/insecure-registry modes, and no key-rotation object that can cross the gap.

Architecture

Both delivery paths converge on the same staging FSM and apply pipeline — the client cannot tell (and does not care) whether an update arrived over the internet or on removable media. The transfer medium is untrusted by construction: the vendor's DSSE signature and the plan's per-artifact sha256 pins are the only trust anchors, and both are checked inside the gap against a locally provisioned trust root.

VENDOR (connected) THE GAP CUSTOMER (airgapped) NO NETWORK Build & sign update plan Ed25519 DSSE · digests pin every artifact Gather pinned artifacts binaries (per target) · .gtbundle packs op updates export → one .gtupdate archive (tar+zstd) op updates publish → updates.greentic.cloud same signed plan, connected lane USB · diode op updates import (scanner) verify: envelope sig · plan sig vs local trust-root.json · every blob sha256 path safety · quarantine on failure trust-root.json provisioned offline Staging FSM (shared) Inbox → Staged → Applying → Applied content-addressed blobs · flock · single-flight op updates apply snapshot → env_apply / binswap → rollback In-gap server static files or greentic-plan-server fleet polls, same protocol (Tier 2) connected runtimes poll → same FSM
Both paths converge on the same staging FSM. The gap is crossed by one signed archive; all verification happens on the customer side against an offline-provisioned trust root.

The .gtupdate envelope

A plain tar archive (+zstd) — streamable (written and read sequentially, blobs verified inline, never buffered whole in memory), splittable with standard tools for multi-volume media, and multi-GB-capable. SquashFS was rejected: it suits random-access runtime reads (.gtbundle), but the envelope is read once at import and unpacked into the staging tree.

my-update.gtupdate
├── manifest.json          # content list: plan_id, sequence, created_at, entries[]
├── manifest.json.sig      # DSSE envelope over manifest.json
├── plan.json              # exact signed plan bytes (unchanged from connected lane)
├── plan.json.sig          # DSSE envelope — same sidecar as today
├── blobs/
│   └── sha256-<hex>       # one file per artifact/binary, content-addressed
├── trust-rotation.json    # OPTIONAL: greentic.trust-rotation.v1, DSSE-signed
└── trust-rotation.json.sig
  • Binary payload representation (gate finding 2): blobs carry the raw inner executable, content-addressed by the existing BinaryArtifact.digest — which already hashes the inner binary, not the release archive (plan.rs:121-124). Release archives are never carried in-band; manifest entries declare media_type + target triple; consumers (binswap, import) take the executable directly and skip the unpack path.
  • Delta exports are inventory-based, not sequence-based (gate finding 3): staged blobs are per-plan and retention-evicted (staging.rs:798-836), so "applied sequence N" proves nothing about possession. Import maintains a durable, refcounted import CAS and writes a signed import receipt (env, root version, held digests); export takes --base-receipt or --base-envelope <digest>. Import preflight resolves every digest the plan references before admission and fails closed listing what's missing — a delta can never verify yet be unapplyable. Import stays idempotent, so a full export is always the safe fallback.
  • Target filtering: --targets x86_64-unknown-linux-gnu,… slims binary payload; default is all targets (safe but large).
  • Verify-on-import (the "import-side scanner" from the stub): manifest signature, plan signature against the target env's trust root, every blob sha256 — quarantine on any failure. Verify-on-apply is unchanged: the existing staging FSM re-verifies the plan DSSE and every artifact digest again at apply time.
  • Strict archive grammar (gate finding 4 — the scanner is a DoS surface: container metadata parses before authentication completes): fixed entry order (manifest first), exact path allowlist, regular files only (no symlinks, hardlinks, sparse files, devices, FIFOs), no duplicate paths, bounded entry count / per-entry size / total expanded bytes / compression ratio, disk-space reservation, extraction into a quarantine directory, atomic commit. Ship-gated test battery: decompression bomb, duplicate entry, hardlink, sparse file, special file, oversized manifest. Precedent in-house: binswap's MAX_UNPACK_BYTES and zip-slip guards.

Design decisions (tension resolutions)

1Signed source URLs

Artifact URLs live inside the signed plan bytes, and in-gap clients can't reach github.com/ghcr.io. Resolution: keep one vendor-signed plan end-to-end — never re-sign in the gap. Primary: in-band envelope artifacts (source=None). Secondary (Tier 2): client-side source overrides (blob_base_url / registry mirror in update-channel.json) — mirrors are client config, not signed content (TUF principle); digest pinning keeps any mirror safe.

2Envelope format

.gtupdate = tar+zstd, streaming, content-addressed blobs, idempotent import. Binary blobs are raw inner executables keyed by the existing plan digest (never archives), and the format is locked down by a strict archive grammar with resource bounds. Full spec above.

3Delta exports

Basis = a signed import receipt (or explicit base-envelope digest) carried back out of the gap — never a sequence watermark, because staged blobs are per-plan and retention-evicted. A durable, refcounted import CAS holds blobs across plans; import preflight fails closed on any missing digest. Full export remains the always-correct fallback.

4Tiers & in-gap serving

Tier 1 needs no server at all. Tier 2 starts as a static content-addressed directory served by any HTTP server (nginx/caddy) — the read contract is 3 anonymous GETs; monotonic-sequence enforcement lives in the import tool. Content updates need no client changes; binary self-update requires greentic-start ≥ the Phase C version (today's runtimes skip source=None binaries). A turnkey greentic-plan-server binary (new public crate, file-backed, SSE) comes later. The private in-memory greentic-updates-server must never become a dependency.

5Trust across the gap

File-pinned trust roots on bootstrap media (the offline CLI already exists); did:web has no airgap role. Key rotation travels as a dedicated DSSE-signed greentic.trust-rotation.v1 object in the envelope — never inside a plan (preserves the keep-out invariant). Because plan verification is 1-of-N over one flat key list (signing.rs:313-328), rotation is NOT signable by everyday update keys: the trust root gains key roles (update vs rotation), rotation objects require a threshold of rotation-role keys from the first release, and import is two-phase — verify the entire envelope first (rotation under the old root, plan under the candidate root), then atomically commit root change + plan admission together, with a monotonic root_version and recovery journal. A quarantined blob can never leave a half-rotated trust root behind.

6Freshness

Advisory staleness warnings (plan created_at age vs configurable threshold), never hard expiry by default — hard expiry would brick long-offline environments, the one failure mode airgap customers cannot tolerate. Optional hard-reject mode for customers who want it.

7Auto-apply safety

Import stages to Inbox, not Staged — a runtime configured on_update: apply must not auto-apply mid-import. Explicit --stage promotes.

8Scope

In: bootstrap media (first install into the gap = same envelope, special case). Out: Replicated-style license/entitlement-gated exports — a product decision, documented as such so it's never mistaken for a security gap (trust is key-based).

Deployment tiers

Tier 1 — Sneakernet direct (ships first)

export → removable media → op updates import → existing staging FSM → op updates apply. No server, no daemon, no new binary — one new CLI verb plus the envelope library. Covers single-host and small installs; the operator walks the media to each machine.

Tier 2 — In-gap fleet server

The import tool additionally pushes plans into an in-gap serving directory (any HTTP server) or a turnkey greentic-plan-server. Runtimes inside the gap poll it using the same connected-lane protocol; SSE degrades gracefully to poll-only. One import point → whole fleet converges. Floor: binary self-update needs greentic-start ≥ the Phase C version (one-time out-of-band enrollment upgrade); content updates work with today's clients.

Tier 3 — Commercial hardening (later)

Durable turnkey server polish, M-of-N trust quorums, envelope SBOM verification, per-customer export tooling if the product ever wants entitlement gating.

Trust & safety model

  • The medium is never trusted. USB sticks, DVDs, diodes carry sealed content; the import scanner and the apply path independently verify signatures and digests against the locally provisioned trust root.
  • Fail closed. Missing trust root = empty trust root = every plan rejected. Unknown signing keys rejected. Tampered blob = quarantine.
  • No downgrades. Plan sequence must be strictly monotonic per environment — enforced client-side, so it survives any import ordering or replayed media.
  • No config smuggling. A signed plan can never carry updates channel config or trust-root changes — existing invariants, preserved. Rotation is a separate object requiring a threshold of dedicated rotation-role keys, verified and committed atomically with the rest of the envelope.
  • Deny-by-default stands. An environment that never opts into updates contacts nothing and accepts nothing.

Phased roadmap (each phase independently shippable)

Phase A — Envelope library + op updates export

Repos: greentic-update, greentic-deployer

Implement envelope.rs (build/scan, streaming tar, strict archive grammar with resource bounds, sha256 re-verify per blob) and the deployer export verb (gather plan + pinned blobs from their sources or the staging tree; --targets).

Ship criteria: exported .gtupdate passes scan_envelope(); round-trip, tampered-blob, and path-traversal tests green PLUS the hostile-archive battery (decompression bomb, duplicate entry, hardlink, sparse file, special file, oversized manifest).

Phase B — op updates import (Tier 1 complete)

Repos: greentic-update, greentic-deployer, greentic-start

Import verb: scan → durable refcounted import CAS → preflight (resolve every plan digest, fail closed) → staging pipeline. Signed import receipt written on success; receipt-based delta export lands here. Lift the source=None rejection on the import path only; greentic-start consumes staged in-band binaries via binswap; advisory staleness warning.

Ship criteria: zero-network export → USB → import → apply round-trip including binary swap, restart, and rollback, with every existing rollback guarantee intact.

Phase C — In-gap fleet serving (Tier 2)

Repos: greentic-deployer, greentic-deploy-spec, greentic-start, docs; optional new public crate

Import --push-to / static-dir writer; blob_base_url + insecure-registries CLI surface; greentic-start blob-mirror fallback for plan/binary fetch; nginx/caddy layout docs; then the optional turnkey greentic-plan-server binary.

Ship criteria: fleet runtimes at ≥ the Phase C version converge (content AND binaries) from a single import point inside the gap; the minimum client version is documented.

Phase D — Trust rotation + hardening

Repos: greentic-update, greentic-trust, greentic-deployer

greentic.trust-rotation.v1 shipping complete from its FIRST release: key roles (update vs rotation), rotation-key threshold, two-phase verify-then-atomic-commit, monotonic root_version + recovery journal; multi-holder ceremony tooling in greentic-trust; envelope SBOM; hard-reject staleness mode; full E2E including rotation, quarantine-mid-rotation, and receipt-based re-import.

Ship criteria: key rotation crosses the gap without manual trust-root surgery; a single compromised update-signing key can neither rotate the root nor leave it half-rotated after a quarantine.

Execution steps (this plan's immediate work)

  1. Author the design document at greentic-update/docs/airgap-update-delivery.md — it lives with the envelope code it governs. Sections: exec summary · problem · industry landscape · design principles · architecture · .gtupdate spec · tension resolutions · trust model · freshness/anti-rollback · tiers · client-protocol invariant · bootstrap media · roadmap · risks · wire-contract appendix. Every code claim cites file:line.
  2. Land via feature branch + PR on greentic-update, base develop. Doc-only change: skip cargo update/coverage; still run bash ci/local_check.sh before the PR.
  3. Run the adversarial design gate (Codex review) over the doc before any Phase A implementation planning; fold findings in or reject them explicitly in the risks section.

Design-gate round 1 (Codex adversarial review, 2026-07-28)

All four findings were verified against the code and confirmed; each is folded into the design above.

FindingVerified premiseResolution
HIGH Single-signature rotation lets one compromised key rewrite the root Plan verification is 1-of-N over a flat key list (signing.rs:313-328); the original design applied rotation before plan verification, single-signer first. Decision 5: key roles (update vs rotation), rotation-key threshold from the first release, two-phase verify-then-atomic-commit, root_version + recovery journal.
HIGH Binary blob representation undefined; fleet clients can't converge binaries BinaryArtifact.digest hashes the inner executable while source points at an archive (plan.rs:121-124); unchanged greentic-start skips source=None (revision_serve.rs:2073). Decisions 2 & 4: raw-executable blobs keyed by the existing digest, media types in the manifest, greentic-start added to Phases B/C, Tier 2 minimum client version.
MED Sequence-based deltas can't prove the recipient holds omitted blobs Staged blobs are per-plan and retention-evicted (staging.rs:798-836) — no durable CAS, no possession proof. Decision 3: durable refcounted import CAS, signed import receipts as the delta basis, import preflight fails closed on missing digests.
MED Scanner permits resource-exhaustion / tar-structure attacks The original scanner spec covered signatures, digests, and path safety only; archive metadata parses before authentication completes. Decision 2: strict archive grammar, resource bounds, quarantine extraction, atomic commit, hostile-archive test battery gating Phase A.

Risks & open questions

RiskMitigation
R1Multi-GB envelopes vs removable-media limits and diode bandwidth.Receipt-based delta exports; tar splits with standard tools; full export always safe because import is idempotent.
R2source=None code paths have never run in production; every consumer currently errors or skips.Phase B audits every artifact.source consumer; content-addressed staging makes blob origin invisible downstream — only the ingestion point changes.
R3Import receipt lost, or never carried back out of the gap.Export falls back to a full envelope; deltas are an optimization, never a correctness dependency.
R4Rotation ceremony practicality — threshold-of-rotation-keys is required from day one, and offline co-signing across key holders is operationally heavy.Rotations are rare; Phase D owns the ceremony tooling and UX (pass-the-object-on-USB workflow).
R5Heterogeneous target triples inside a gap; a forgotten triple forces a re-export.Default to all targets (large but safe); --targets for size-conscious operators.
R6Accidental dependency on the private in-memory greentic-updates-server.Tier 2 is static files + (later) a new public crate; the private server is explicitly out of the dependency graph.
R7Fleet binary convergence needs greentic-start ≥ the Phase C version.One-time out-of-band enrollment upgrade; document the floor (same shape as the _-broadcast bootstrap deadlock).
R8The durable import CAS adds disk pressure inside the gap.Refcount/GC rules plus an op updates cas gc verb, in Phase B scope.
QBroadcast channel _ semantics in-gap: one shared plan directory or per-env directories?Open — resolved in the design document.

Appendix — wire contracts touched

Plan server (unchanged; what in-gap serving must replicate)

RouteAuthPurpose
GET /v1/environments/{id}/plananonymousexact signed plan bytes
GET /v1/environments/{id}/plan.siganonymousDSSE envelope sidecar
GET /v1/environments/{id}/plan/metaanonymous{sequence, plan_sha256, uploaded_at} — torn-read guard
GET /v1/environments/{id}/updates/streamanonymousSSE hint (optional in-gap; clients fall back to poll on 404)
POST /v1/environments/{id}/planX-Api-Keyupload; monotonic sequence, 409 on conflict (Tier 2 --push-to uses this)

New schemas introduced by this design

SchemaCarrierNotes
greentic.update-envelope.v1 (manifest.json).gtupdate archivecontent list (paths, digests, media types) + DSSE signature; the import scanner's first check
greentic.trust-rotation.v1optional envelope memberadd/remove keys, effective-after-sequence, root_version; requires a threshold of rotation-role keys (never everyday update keys); never inside a plan; committed atomically with plan admission
greentic.import-receipt.v1written by import, carried out of the gapsigned inventory: env, root version, held blob digests — the delta-export basis
update-channel.json additionsclient-local configblob_base_url, staleness threshold — mirrors are client config, never signed content

Source plan: plans/airgapped-updates-design.md in the Greentic workspace. Industry references: Replicated air-gap install/update flow, Zarf (zarf-dev/zarf), Hauler, TUF/Sigstore offline-root guidance. Code facts verified 2026-07-24 against the develop checkouts of greentic-update, greentic-start, greentic-deployer, greentic-bundle, greentic-distributor-client, greentic-trust.