Holger Secure Update Gateway — MVP architecture
Status: maintained design document (not generated). Source-of-truth order still applies: executable tests and runtime wiring outrank this document. Keep it in sync with
update-bundle/,server/lib/src/security.rs, and the operator guide.
1. Product purpose
The Holger Secure Update Gateway is not a generic Nexus/Artifactory replacement for this milestone. It has one job:
Only software, firmware, installers, archives, and other update artifacts that have been verified, reviewed, approved, signed, and recorded may cross into a protected environment. Every delivered byte must be traceable to its source, its verification results, its approvals, and its final release bundle.
The primary use case is moving an approved software or firmware release from an internet-connected intake environment into an isolated IT/OT environment across an airgap. The MVP does not install software, flash PLCs, or modify process equipment — it delivers a verified, approved, signed bundle and serves it read-only to a maintenance workstation, which performs the actual (human-controlled) update.
2. Trust boundaries
┌─────────────────────────┐ airgap ┌──────────────────────────┐
│ INTAKE (internet-side) │ (USB / one-way / │ PROTECTED (OT/IT-side) │
│ - holger-agent (strict) │ optical, offline) │ - holger-mikro (critical,│
│ - holger-bundle prepare │ ═══════════════════▶ │ read-only, zero-egress)│
│ - review + 2 approvals │ a single signed │ - holger-verify (offline)│
│ - holger-bundle finalize│ *.holger.znippy │ - holger-server bundle │
│ - holger-verify (gate) │ file crosses │ import (anti-rollback) │
└─────────────────────────┘ └──────────────────────────┘
outbound HTTP allowed NO outbound network ever
- Intake environment — internet-connected. Fetches the vendor artifact (via the existing agent connectors under strict transfer mode), records a provenance receipt, attaches evidence (SBOM/VEX/scan/advisory snapshot), and produces a candidate bundle. It is assumed hostile-adjacent: everything it produces is treated as untrusted input by the protected side.
- Review and release environment — where two distinct human roles (
security-reviewerandsystem-owner) each sign an approval, and a distinct release signer produces the final release signature. May be the same physical network as intake or a separate segment; the two-person rule does not depend on network topology, only on distinct certificates. - Airgap transport — a single file (
<supplier>-<product>-<version>.holger.znippy) copied on removable media. The transport is assumed lossy and hostile: the bundle may be truncated, byte-flipped, replayed, or swapped. - Protected OT node — runs
holger-mikroin the Critical security profile and ReadOnly operation mode. Zero-egress by dependency construction (no reqwest/ureq). Serves the imported, approved bundle to a maintenance workstation. Never reaches the internet, never accepts a network write.
3. Update bundle lifecycle
External source
→ strict intake + provenance receipt (holger-agent --strict --receipt R.json)
→ candidate bundle (holger-bundle prepare)
→ immutable evidence snapshot (evidence copied into the bundle, digested)
→ two distinct signed approvals (holger-bundle approve, ×2 distinct certs/roles)
→ final release signature (holger-bundle finalize, distinct release signer)
→ airgap transport (copy the single *.holger.znippy)
→ offline verification (holger-verify verify --policy ...)
→ anti-rollback check (sequence > highest_accepted)
→ atomic import into protected data root (holger-server bundle import)
→ read-only serving (holger-mikro, Critical + ReadOnly)
→ audit + integrity scrub (holger-server scrub / backup / audit verify)
4. Bundle format — holger.update-bundle/v1
The bundle is a znippy archive (no new binary format is invented) with this layout:
holger-bundle/
manifest.json # bundle envelope: schema id/version, member index, digests
core.json # the immutable release candidate (signed target of approvals)
payload/ # the update artifacts (firmware/installers/archives)
evidence/
source-receipt.json # strict transfer receipt (provenance)
sbom/ # CycloneDX or SPDX documents (opaque bytes)
vex/ # VEX documents (opaque bytes)
scan/ # security scan report(s) (opaque bytes)
advisory/ # advisory-DB snapshot metadata (digest + non-production marker)
exceptions/ # accepted-exception documents
approvals/ # <role>.<fingerprint>.approval.json — signed approval statements
signatures/
<role>.<fingerprint>.sig # detached ed25519 signature over the approval bytes
<role>.<fingerprint>.crt # signer X.509 certificate (DER)
release-manifest.json # {path,size,sha256,blake3} for EVERY other member
release.sig # ed25519 over the exact release-manifest.json bytes
release.crt # release signer X.509 certificate (DER)
4.1 core.json (schema holger.update-bundle/v1)
core.json is written to disk once, and every downstream digest and signature is computed over those exact stored bytes — never over a reparsed-and-reserialized value. Maps use BTreeMap so serialization is deterministic; timestamps are UTC RFC 3339. Modeled fields (see update-bundle/src/core.rs for the authoritative struct):
schema_version, bundle_id, sequence, created_at, expires_at,
producer, supplier, product, release_version,
supported_hardware_models, supported_hardware_revisions,
source_provenance { source_kind, source_uri, source_repository, source_asset_path,
fetched_at, reported_size, vendor_checksum, vendor_signature_status,
tls_origin, transfer_receipt_digest },
payloads[] { relative_path, media_type, size_bytes, sha256, blake3 },
evidence[] { relative_path, media_type, size_bytes, sha256, blake3, evidence_kind },
policy_result { policy_digest, advisory_snapshot_digest, verdict, non_production },
approvals_required (informational mirror of the trust policy),
supersedes[], withdraws[]
release_signature is not stored inside core.json (a value cannot sign itself); it lives in signatures/release.sig and covers release-manifest.json, which in turn covers core.json.
4.2 Candidate digest and approvals
- Produce exact
core.jsonbytes. candidate_digest = sha256(core.json bytes).- Each approval statement (
approvals/<role>.<fp>.approval.json) references thatcandidate_digestand carries:candidate_digest, approval_role, signer_certificate_fingerprint, signer_subject, approved_at, decision, optional_comment, signature_algorithm, signature_path. - The approval statement bytes are signed with the approver's private key (ed25519); the detached signature is stored at
signatures/<role>.<fp>.sigand the signer certificate (DER) atsignatures/<role>.<fp>.crt. - Finalization verifies every approval signature, enforces the trust policy, then builds
release-manifest.json(every member'ssha256+blake3) and signs it. - The release signature covers the complete digest manifest — so it transitively covers every payload, every evidence file,
core.json, and every approval statement and signature.
The optional_comment is never part of authorization logic. Roles are authorized by the signer's certificate fingerprint against the trust policy — not by whatever role string a signer writes into a statement.
4.3 Trust policy — holger.bundle-trust-policy/v1
A RON document (trust-policy.ron) defining:
trusted_roots[] # DER root certs the signer/release certs must chain to
approved_signers[] { fingerprint, subject, roles[] } # who may exercise which role
required_approval_roles[] # e.g. ["security-reviewer", "system-owner"]
minimum_distinct_approvers # default 2
trusted_release_signers[] # fingerprints allowed to produce the release signature
release_signer_must_be_distinct # release signer ≠ any approver (default true)
maximum_bundle_age # seconds; created_at must be within this window
require_expiry # bundle must carry expires_at
require_sbom / require_vex / require_scan
allowed_products[] / allowed_models[]
The shipped critical sample requires: minimum_distinct_approvers = 2, roles security-reviewer and system-owner, two distinct certificate fingerprints, a trusted release signer, and a valid final release signature. One certificate cannot satisfy two required roles in the same bundle.
5. Signing model
- Algorithm: ed25519 (RustCrypto
ed25519-dalek), reusing a dependency already in the lockfile. No new crypto algorithm, no proprietary format, no OpenSSL — the trust model stays rustls/RustCrypto. - Signer identity: an X.509 certificate (DER).
fingerprint = sha256(cert DER);subject= the certificate subject. The ed25519 public key is taken from the certificate'sSubjectPublicKeyInfo. Test PKI is generated in-process withrcgen; no production private keys are ever committed. - Everything is signed over exact stored bytes. Verification recomputes digests over the bytes as stored and compares to
release-manifest.json, then verifies signatures — it never trusts a reparse.
6. Audit model
- The protected node runs in the Critical profile where audit is fail-closed: a sink-init failure aborts startup, and a required pre-operation audit-write failure blocks the operation (no silent
NoopAuditLogfallback). - Offline bundle actions (
prepare/approve/finalize/verify/import/extract/scrub/backup/ restore) are recorded with bundle id, candidate digest, sequence, target product, actor, action, status, and byte counts — never a private key, token, password, or secret file content. - Audit remains the existing append-only Arrow-IPC format (0700 dir / 0600 segments on Unix).
holger-server audit verifychecks segment readability offline.
7. Anti-rollback model
The protected node keeps per-(supplier, product) accepted state: highest_accepted_sequence, last_bundle_id, last_core_digest, accepted_at. Import rejects: a lower sequence; reuse of an already-consumed sequence with different bytes; a different bundle reusing an existing bundle id; expired bundles; target mismatches; invalid approvals; invalid final signature. State is written atomically (temp + fsync + rename), mirroring the promote store's create-once idiom.
There is no casual --force. A break-glass rollback, if ever needed, is a separate, visibly dangerous command requiring an additional signed rollback authorization, a reason, and an audit record — and is out of scope for this MVP.
8. Backup and restore model
holger-server backup createcaptures the imported bundle store, anti-rollback state, import receipts, promotion store/lineage, audit segments, hosted SBOM/property staging, public trust roots, non-secret config, and build metadata — with a manifest of hashes for every included file. It excludes private keys, client keys, inline tokens, passwords, and vault material (inline secrets in a config are redacted or the config is excluded with a note that external secret restoration is required).backup verifyis offline and detects a single changed byte.backup restorerestores only into an empty target by default, verifies first, stages into a scratch data root, rejects unsafe paths/links, recomputes hashes, and atomically activates the restored root — never overwriting a running instance's live data. It reports which external secrets/keys must be restored separately.
9. Non-goals (this milestone)
The MVP does not: install or flash updates; implement TUF/Uptane; use an HSM or PKCS#11; perform online OCSP/CRL revocation in the protected zone; provide active-active HA or multi-site continuous replication; automatically deploy to PLCs; replace a SIEM; guarantee SBOM semantic completeness merely because one is attached; or treat the demo advisory database as production vulnerability intelligence.
10. Known MVP limitations
- Signer trust is a fingerprint/subject allowlist plus a single-level chain-to-root check; there is no revocation and no full path-building.
- Container is a single znippy archive; no delta/patch bundles.
- Serving surface for the vertical slice is the raw backend over TLS: the protected node serves the signed bundle as one artifact and the maintenance workstation runs
holger-verify extract. Materialized per-payload ecosystem serving is a follow-up. - The advisory snapshot is metadata + a non-production marker unless a real imported snapshot digest is supplied.
11. Planned migration
TUF/Uptane metadata roles; HSM / PKCS#11-backed signing; CSAF advisory ingestion; online (offline-CRL) revocation; and deployment orchestration are all deliberately deferred. The bundle schema is versioned (holger.update-bundle/v1) and the trust policy is versioned (holger.bundle-trust-policy/v1) so these can be layered without breaking existing bundles.