Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

docs/v1/secure-update-gateway-mvp.md

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

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

  1. Produce exact core.json bytes.
  2. candidate_digest = sha256(core.json bytes).
  3. Each approval statement (approvals/<role>.<fp>.approval.json) references that candidate_digest and carries: candidate_digest, approval_role, signer_certificate_fingerprint, signer_subject, approved_at, decision, optional_comment, signature_algorithm, signature_path.
  4. The approval statement bytes are signed with the approver's private key (ed25519); the detached signature is stored at signatures/<role>.<fp>.sig and the signer certificate (DER) at signatures/<role>.<fp>.crt.
  5. Finalization verifies every approval signature, enforces the trust policy, then builds release-manifest.json (every member's sha256+blake3) and signs it.
  6. 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

6. Audit model

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

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

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.