10. Security
Post-quantum status
- TLS. Every Holger door offers the hybrid key exchange X25519MLKEM768 first, then X25519, secp256r1 and secp384r1: the HTTP/OCI and gRPC doors and the process default (
door_provider(),server/lib/src/exposed/tls.rs, the pqtls provider ofnordisk-pairing), and the estate front's :443. A classical client still gets X25519. - WireGuard. Every estate link (front ↔ box) and every downstream link carries a pre-shared key, which adds a symmetric secret to the classical handshake (chapter 6). The installer link has no PSK; its payload is mutual TLS.
Safe defaults
- Listeners. An endpoint that is not loopback by name needs an authentication method (
holger-listen-1) and TLS (ron_tls), orinsecure: trueon the endpoint where something else encrypts the wire (holger-listen-2). The estate's and appliance's RONs sayinsecure: true: they serve over WireGuard or the LAN. A Holger with no authentication serves only its own machine. - Roles. Authentication with no role policy serves with
default_role: Reader.security_profile: Criticalneeds a role policy in the file. - Approvals.
steps:needs two approvers by default. - The client.
holger-cli --at host:portuseshttps://unless the address names this machine. - Kubernetes. The Helm chart and the raw manifests turn authentication on with an operator token Secret (
<release>-auth, orholger-authfor the raw manifests). - Approvers. An invite that makes a Security Approver needs a person's stepped-up passkey session; the console's machine token is refused (
holger-auth-6). An admin is never invited. - Egress. Every socket Holger opens asks
egress::checkfirst: the SIEM sinks, the off-site S3 copy, the WireGuard tunnels.network: Offlinelimits the SIEM to on-site collectors (loopback, private, link-local and ULA addresses). - The first admin. A plain start with passkey sign-in and no admin writes a single-use first sign-in link to
<data>/holger-passkeys/first-sign-in.url, mode 0600; the passkey enrolled with it is the admin (chapter 3). - No intel, no pass. With no advisory database, or one past
intel.block_after_days, the vulnerability scan holds every package (HOLGER-INTEL-MISSING,HOLGER-INTEL-EXPIRED; chapter 5). - Every package in its ecosystem. A package file that would reach the advisory scan as a raw file is held (
holger-no-ecosystem; chapter 5).
Zero-egress guards
What may dial out is decided by the build and the RON, and refused by name otherwise:
| code | what is refused |
holger-offline-1 |
any outbound dial of a Holger with network: Offline, before a socket opens (pull-through, mirror, twin follower, estate link, WireGuard tunnel); each refusal is audited (server/lib/src/egress.rs, check) |
holger-offline-2 |
a RON that says network: Offline but needs the network: a *-remote repository, a mirror:, a twin role, recharge_from, online intel, a pull-through source with a url, a sync upstream with a url, an estate build (server/lib/src/lib.rs, validate_network). In a downstream build, an offline downstream that names its upstream is refused too, and the process then makes no network call at all |
holger-offline-3 |
a remote repository that names no upstream address |
holger-downstream-1 |
a downstream config that asks for more than its one upstream Holger |
holger-downstream-2 |
a downstream's dial to anything but its upstream |
holger-boundary-1 |
a boundary: OneWay Holger that names a way out (a mirror, an export door, a sync receipt, an outbound target) |
holger-boundary-2 |
boundary: Fetch in a build without the fetch feature |
The downstream build (holger-downstream) carries the on-prem gateway's rules (server-lib feature gateway): one upstream at most, no upload, and an omitted boundary: is OneWay, always checked. The proofs:
server/cli/tests/the_default_build_dials_nothing.rs: the realholgerbinary under a connect trap makes no connection and no DNS lookup;server/cli/tests/one_way_ships_no_client.rs: the one-wayholger-downstreamlinks no HTTP client, no WireGuard dialer and no fetch code (cargo tree);server/lib/tests/an_offline_holger_makes_no_network_call.rs: the boot refusals (holger-offline-2, in the downstream build too) and the dial refusals (holger-offline-1);server/lib/tests/a_downstream_dials_only_its_upstream.rs.
cargo xtask ci runs the server library with the gateway set (sync, fetch, downstream-wg, supply-chain, gateway) as its own step, gateway-set (xtask/src/ci.rs).
Signed apt and dnf metadata
The deb and rpm chain views, and archive-backed deb and rpm repositories (ron_archive_path), sign their metadata with the box's repository key, so apt and dnf check it in their normal configuration (chapter 3):
- deb:
dists/<suite>/InRelease(the clear-signedRelease) anddists/<suite>/Release.gpg; - rpm:
repodata/repomd.xml.asc.
Each signature is over the exact index the repository serves, and is made again when that index changes (server/lib/src/signed_index.rs). Holger signs the metadata, not the packages; an rpm's own signature is its vendor's. The key signs only:
- the index of the repository's own type, at a plain Debian suite word (
[A-Za-z0-9._+~-], not.or..), because the suite from the URL is written intoSuite:; - for a chain view, the index synthesized over the AVAILABLE packages. A
Release,InReleaseorrepomd.xmlthat came through the chain is not a file of the view, so a replayed olderInReleasecan never stand in for the current one; - for an archive-backed repository, the index the archive serves (an index the operator packed into the archive is the operator's statement).
Make the key once per box, then restart:
holger repo-key create --config holger.ron # prints the public key
holger repo-key show --config holger.ron
- The key is Ed25519 OpenPGP, kept at
<data root>/repo-signing/secret.asc(directory 0700, file 0600).createnever replaces an existing key, because clients pinned its public half. A key file others can read refuses the start (server/cli/src/repo_key_cli.rs,server/lib/src/repo_sign.rs). - The data root is the parent of the audit directory. Set
audit: (dir: "<absolute path>")orHOLGER_DATA, so the key lands where the server reads it. - The key file is made with
O_EXCL, set 0600 before the key is written, and published with a hard link: a left-over temporary is removed and never reused, and a secondcreatethat races the first never replaces its key. - The public key is at
GET /-/repo-key.asc, open to everyone. The console's apt and dnf setup names its fingerprint as the key the client trusts. A downstream signs its views with its own key, made on it, and its console names that key (.nornir/deb-rpm-native-metadata-guide.md, "A downstream's key and the chain of trust"). - A box without a key serves unsigned metadata, warns at every start ("… clients need [trusted=yes] or gpgcheck=0; make one with
holger repo-key create --config <ron>"), and answers 404 at/-/repo-key.asc.
Tests (server/lib/tests/the_chain_serves_apt_dnf_helm_maven_go_views.rs): a_box_with_a_repository_key_signs_its_deb_and_rpm_metadata, a_box_without_a_repository_key_serves_no_signature_and_says_how_to_make_one, and real_apt_and_dnf_verify_the_signed_metadata_and_refuse_it_tampered (real apt on Debian trixie and microdnf on Fedora 42; one changed byte or stripped signatures are refused), an_archive_backed_deb_and_rpm_repository_signs_its_metadata_too, real_apt_and_dnf_verify_an_archive_repositorys_signed_metadata, a_metadata_file_through_the_chain_never_stands_in_for_the_signed_index and a_downstream_signs_its_views_with_its_own_key_and_its_console_names_it. The signer's security review is in the lane handback hol-sign-review.md (HOL-SIGN-2).
Dependency advisories
cargo deny --locked check (advisories, bans, licences, sources; policy in deny.toml) runs in the security workflow (.forgejo/workflows/security.yml) and as the deny step of cargo xtask ci. .cargo/audit.toml holds the same ignore list as deny.toml; the test xtask/tests/audit_follows_deny.rs keeps them equal.
The admin listener
An endpoint can serve the admin plane on its own listener: ron_admin_url (gRPC) and ron_admin_http_url (HTTP).
- The admin listener serves the admin plane only:
AdminService,PromotionService,AuditService,TokenService,IdentityService, jobs, admission, migration and the thin client's doors; over HTTP/-/auth,/-/bundle,/-/intel,/-/intake,/-/audit,/-/flow,/-/twin,/-/sandboxand the sync administration doors. - The data listener keeps the package doors. Each listener answers the other plane's doors with
holger-listen-3. ron_admin_allow: ["10.0.0.0/8", ...](CIDRs) is required with an admin listener (holger-listen-5). Any other peer is dropped and audited (holger-listen-4).- Without an admin listener, one listener serves both planes.
Revoked client certificates
ron_tls.crl names a CRL file (PEM or DER) or a directory of them. The gRPC and HTTP doors that verify clients against client_ca check it:
- A revoked client certificate fails the handshake, and the refusal is audited.
- A changed CRL is read again at the next handshake.
- A CRL that cannot be read, is not a CRL, is not signed by the client CA or has expired refuses the start (
holger-tls-1). security_profile: Criticalrequires a CRL whereverclient_cais set.- The twin's own mutual-TLS door revokes through
nordisk-twin, not a CRL.
Key files and hardware signers
- Key files. A private key file Holger loads is refused when its group or others have any permission bit, or when another user owns it (
holger-key-1). This covers the door TLS key, the attestation signer, aStaticTokentoken_file, the passkeysession_key_file, the mirror client key, the sync key, the export-push signer, the apk key, the downstream's WireGuard identity and theholgerCLI's signing key. One other shape is accepted: a Kubernetes secret volume (owned by root, the process's group, at most 0440); the Helm chart and the manifests setdefaultMode: 0440. Keys Holger writes are 0600. - PKCS#11. With server-lib feature
pkcs11(off by default), the attestation signer can live on a token:attestation: (signer_hsm: Some(Pkcs11(module, token_label, key_label, pin_file)), signer_cert: ...). It signs DSSE with ECDSA P-256 or Ed25519 on the token. Refused asholger-key-2: a build withoutpkcs11, a missing token or key, a refused PIN, or a key file and a token both named. Tested against SoftHSM2.
Coming: the same key-file check for the operator tools' keys (holger-cli bundle approve|finalize --key, the intel signer files).
A crafted bundle cannot crash the native verify
A bundle's znippy index is Arrow IPC. A hostile one (an attacker-chosen 1 TiB body length, buffer overruns, schema or boolean-buffer panics) used to abort or panic verify_bundle_file before any signature was checked.
- znippy-common 0.10.3 adds
ipc_guard: every message length, field type, node and buffer is checked against the bytes actually present before Arrow allocates anything, and all 11StreamReadersites go through it. The format and the open speed are unchanged. - Holger's crates require znippy-common 0.10.3 (holger
874dbd08). A hostile index is now a container refusal (holger-update-28, "bundle container could not be read"), never an abort. - Tests:
update-bundle/src/tests/hostile_index.rsand traits' recorded format version. Fuzzed:ipc_stream110.9 M executions andarchive_open5.1 M after its one find, with 0 crashes.
OpenZL allocation cap
OpenZL took its internal buffer sizes from the frame: a crafted 36-byte frame made Holger allocate and fill 1 GiB.
- A frame may not declare more than its chunk's size, and
extract_file_verifieddecodes each chunk under a budget of 2 × the declared size + 1 MiB (openzl-sys-rs 0.4.2with_alloc_budget, znippy-common 0.10.4; holgerb03d9123). - The crafted frame is refused, and the process peak grows by under 64 MiB. Normal reads are unchanged (259.3 ms against 260.8 ms).
- Test:
update-bundle/tests/openzl_alloc_budget.rs.
The time floor
An airgap box judges a bundle's expiry, its maximum age and its certificates against its clock, and a clock can be rolled back from the console or the BIOS. So Holger judges every import at the later of its clock and its time floor, <data_root>/holger-time/floor: the highest trusted time it has seen (server/lib/src/time_floor.rs).
- What raises it. A verified estate clock, the signed
created_atof every bundle it accepted, the signed snapshot time of every intel bundle, and a signed time token. An unverified host clock never raises it: a clock set into the future would leave a floor nothing can lower. - A clock behind the floor by more than 5 minutes is judged at the floor, said in the log and written to the audit log (
holger-time-1). The floor never moves back. A floor file that cannot be read refuses the import (holger-time-3). - The time token. An ordinary signed update bundle of product
holger.time-token/v1whose one payload says the time (holger-time-2when it says none, or one that is not its signedcreated_at). It sets no anti-rollback floor, so it needs no first-import grant.
The operator's steps:
- On the online side, make a token and the policy that trusts its signer (minted under
--signer-diron first use, keys0600):holger time-token make --out time.holger.znippy --signer-dir ./time-signer --trust-policy-out time-trust.ron(--at <RFC 3339>for another time,--valid-days N, default 7). - Carry
time-trust.ronto the airgap side once; carry each token like any bundle:holger bundle import --config holger.ron --bundle time.holger.znippy --policy time-trust.ron, or drop it into the bundle inbox when the box'spromotion.intake.trust_policytrusts the token's signer forholger.time-token/v1. The import saysTIME FLOOR raised to .... holger time-token status --config holger.ronsays the floor and what set it.
Coming: the time floor for the provenance trust-root bundles and for holger bundle scrub, which still judge by the bare clock.
The audit log
The audit log is audit.jsonl in the audit directory: append-only and hash-chained (seq, prev, hash on every line), with a checkpoint signed by the attestation signer every audit.checkpoint_every records. The chain is the source of truth (server/lib/src/audit/).
- Durability. Every batch is
fsynced before any caller returns. Every write holds an exclusiveflockonaudit.jsonl, so two processes (the server and abundle import) extend one chain and never fork it. A checkpoint that cannot be signed is refused (holger-audit-11) and never written unsigned. - Always on. No profile runs without its audit log:
audit: (mode: Off)orenabled: falserefuses the start (holger-audit-13). - Every refusal is audited: a blocked dial, a refused bundle or intel import and an inbox marker (with the code), an importer without the right, and a refused TLS handshake at each door (at most 60 rows a minute).
- At startup the server walks the whole chain. A break refuses the start under
security_profile: Criticaland is recorded in the log under the standard profile. An audit directory that cannot be opened refuses the start in every profile (holger-audit-12). - The index.
<audit dir>/index/holdsMANIFEST.jsonand Arrow IPC segments, withseqandhashon every row. It catches up on every read and is rebuilt from the chain when it is missing or the chain was rewritten; deleting it is safe. - Search. gRPC
AuditService.QueryAudit(by actor, repository, artifact prefix, action, source address, status and time; paged),AggregateAudit(count and bytes per repository, action and day, or per identity, client or status),ExportAudit(the selected rows as one Arrow IPC file), andVerifyAuditChain. On the command line:holger-cli audit query|aggregate|export --out|verify, and offline on the server's own filesholger audit query|aggregate|export --out <file>. The console has no audit view.
Threat model
The system threat model: the assets, the trust boundaries (the online side, the diode or inbox, the airgap side, the downstream, the console, the estate front, the scan sandbox, Heimdall later), the adversaries, every NCSC gap with the code and the test that close it, and the residual risks.
Reporting a vulnerability
SECURITY.md: write to holger@holger.rs, with a subject starting with SECURITY:.
Still in v1
- v1/secure-update-gateway-threat-model.md: the secure update gateway's per-stage threat model (the system one is threat-model.md).
- v1/security-review-2026-09-11.md: the review of 2026-09-11.