Holger Secure Update Gateway — threat model (MVP)
Status: maintained design document (not generated). Pairs with
docs/secure-update-gateway-mvp.md. Each mitigation names the code that enforces it.
1. Assets and adversaries
Assets: the protected OT/IT environment; the integrity + authenticity of every byte that crosses the airgap; the approval record; the audit trail.
Adversaries considered:
| id | adversary | capability |
| A1 | Malicious/compromised upstream source | serves tampered or malicious artifacts, wrong bytes, wrong size, redirects |
| A2 | Man-in-the-middle on the intake network | rewrites downloads, swaps evidence |
| A3 | Malicious insider on the intake side | tries to push an unreviewed/unsigned artifact, or forge one approval |
| A4 | Tampering-in-transit across the airgap | flips bytes, truncates, replays an old bundle, swaps evidence |
| A5 | A single rogue approver | tries to satisfy the two-person rule alone |
| A6 | Attacker with a hostile bundle at the protected import boundary | zip-slip, symlink escape, decompression bomb, rollback, oversize |
| A7 | Local user on the protected node | tries to read identities/secrets from audit or config, or write via the network |
Out of scope (documented): physical destruction of the OT node; a compromised release-signer private key held by a trusted signer (key custody is the operator's responsibility — mitigated only by the two-person rule + distinct release signer); supply chain compromise of the Rust toolchain / crates the gateway is built from.
2. Per-stage attacks and mitigations
2.1 Strict intake (A1, A2)
- Attack: upstream returns fewer bytes / wrong bytes / a partial set and the agent still reports success (the existing best-effort tally behavior).
- Mitigation:
TransferMode::Strict— any failed download/upload, size mismatch, checksum mismatch, hostile path, or missing expected asset makes the operation fail (non-zero exit). A partially-created archive never appears under the final filename (sibling temp + atomic rename). The receipt records per-asset sha256+blake3, sizes, and acompleteboolean. Credentials are stripped from the recorded source endpoint. Enforced inagent/lib/src/operations.rs(*_with_options,TransferOptions); best-effort remains the default so thetransfer-partial-successinvariant is intact. - Reused guards: SSRF allowlist (
world-ssrf-allowlist), same-origin credential attach (connector-same-origin), redirectPolicy::none(), per-component path checks (no-traversal), body-size caps — none are weakened; strict mode only upgrades a tallied failure into a hard error.
2.2 Candidate preparation and evidence (A2, A3)
- Attack: attach fabricated or absent evidence; reference mutable external evidence that can change after review.
- Mitigation:
preparecopies evidence bytes into the bundle and records their sha256+blake3 incore.json; it never references only an external mutable path. Missing evidence is never silently invented. The candidate is produced atomically (temp + rename), temp removed on failure.
2.3 Approvals and the two-person rule (A3, A5)
- Attack: one approver forges both approvals; a signer claims a role they don't hold; an approval for a different candidate is replayed; a signer edits a statement after signing.
-
Mitigations:
- Each approval is an ed25519 signature over the exact statement bytes; any edit breaks the signature.
- Each statement binds
candidate_digest; an approval for a different candidate fails (candidate_digestmismatch). - Role authorization is by certificate fingerprint against the trust policy, not by the role string in the statement (defeats role-confusion / self-asserted roles).
minimum_distinct_approvers = 2over distinct certificate fingerprints; one cert cannot satisfy two required roles; the release signer must be distinct from approvers.- Untrusted approver (fingerprint not in policy) → fail. Unauthorized release signer → fail.
2.4 Finalization and release signature (A3, A4)
- Attack: ship an unsigned or partially-signed bundle; swap a payload/evidence file after signing.
- Mitigation:
release-manifest.jsonenumerates{path,size,sha256,blake3}for every member; the release signature (ed25519) covers its exact bytes. Any swapped byte in any member changes a manifest digest and fails verification before the signature is even checked. Finalize refuses partial/unsigned output and emits a finalization receipt.
2.5 Airgap transport tampering + replay (A4)
- Attack: flip one byte; truncate; replay an older approved bundle; re-use a sequence number with different bytes.
-
Mitigations:
- One changed byte anywhere → a member digest ≠
release-manifest.json→ verify fails (adversarial test asserts this). - Truncation → znippy open failure or missing member → fail.
- Replay/rollback → anti-rollback:
sequencemust exceedhighest_accepted_sequence; an equal sequence with differentcore_digestis rejected; a reused bundle id with different bytes is rejected. - Expiry:
expires_at/maximum_bundle_ageenforced.
- One changed byte anywhere → a member digest ≠
2.6 Hostile bundle at the import boundary (A6)
Every bundle is treated as hostile input. The verifier rejects, before writing any payload:
- absolute paths,
.., empty components, backslashes, NUL bytes; - symlinks and hard links (bundle members are regular files only);
- duplicate normalized paths and case-folding collisions (case-insensitive FS safety);
- unsupported znippy format version (
format-version-pin) and unsupported bundle schema; - oversize manifest, excessive member count, oversize individual member, and aggregate-size arithmetic overflow (checked adds);
- decompression bombs (per-member and aggregate uncompressed caps).
No unwrap/expect/unchecked indexing/panic on untrusted bundle data — parsing returns typed errors with safe, path-free client messages. extract always verifies first; there is no --skip-verification production path. Extraction stages into an empty directory and atomically renames into place (verify-before-extract, no TOCTOU window on the final directory).
2.7 Import atomicity (A6)
- Attack: crash mid-import to leave a torn/partial final state, or a poisoned sequence.
- Mitigation: verify → write to a staging directory under the store → recompute digests after writing → atomically rename staging into the final location → update the accepted-sequence state atomically. A failure leaves no partial final state. Import requires the server stopped or an exclusive store lock.
2.8 Read-only protected serving (A7)
- Attack: a network client PUT/POST/PATCH/DELETEs into the OT node; a mutating gRPC call slips a body through before the gate.
- Mitigation:
operation_mode = ReadOnlyrejects every network mutation before the request body is collected, at the gRPC and HTTP choke points, in addition to the existingwrite-gate-fail-closedRBAC. Promotion, property mutation, SBOM attach, retention execute, and replication-into-node are refused. Reads still honor auth/RBAC. Offline import is only allowed while the server is stopped (exclusive lock).
2.9 Local disclosure on the protected node (A7)
- Mitigation: audit dir 0700 / segments 0600; client-facing errors are laundered to opaque messages (no local paths, cert internals, or policy secrets) via the existing
internal_err()pattern; backups exclude private keys and inline secrets.
3. Critical-profile fail-closed matrix
In security_profile = Critical, startup refuses any configuration that cannot enforce the profile (clear validation error, never a silent rewrite):
| condition | result |
audit.enabled = false |
startup fails |
| audit sink init fails / dir unwritable | startup fails |
| no authentication configured | startup fails |
require_auth_for_reads = false |
startup fails |
| no compiled-in auth provider for the tier | startup fails |
| an exposed endpoint without mTLS (cert+key+client_ca) | startup fails |
| static bearer as the only auth mechanism | startup fails |
| inline secret where an env/file reference is supported | rejected |
| any exposed repository fails to initialize | startup fails (not just that repo disabled) |
/metrics open and not loopback-bound |
rejected (must be authed/disabled/loopback) |
/healthz and /readyz may stay open but expose only minimal status.
4. Residual risk
- Key custody: a stolen approver/release private key defeats the corresponding signature; mitigated by two-person rule + distinct release signer, not eliminated. HSM/PKCS#11 is the planned migration.
- No revocation: a compromised-but-still-trusted signer fingerprint stays trusted until the trust policy is updated and re-imported.
- SBOM completeness: attaching an SBOM proves an SBOM was attached and hashed — not that it is semantically complete.
- Advisory intelligence: the demo advisory DB is explicitly non-production; a real imported snapshot digest must be supplied for production posture.