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

nordisk/holger

.nornir/stinky/03-security-attestation.md

03 — Security, attestation, and the auth-bloat question

Stinky lane 3, 2026-09-20, against holger main = 542e296. One of five sections of one document.

Labels: MEASURED = a number or a tree this lane produced today (cargo tree --offline on the worktree, -e normal = the runtime closure, unique crate names; release builds in a private target dir). READ = a fact read in source, with file:line. DESIGNED = a proposal. Nothing was implemented; patch sketches are in §E.


A. The bloat question — "har holger blivit bloated av passkey?"

A.1 Verdict (MEASURED)

No. auth-passkey costs the appliance 4 crates (nornir-auth-webauthn, ciborium, ciborium-io, ciborium-ll), all pure Rust, 388 → 392 on the mvp-server row with -e normal (the 427 → 431 figure quoted this morning counts build/dev edges; the delta is 4 either way). reqwest, ureq, openssl-sys, webauthn-rs, wasmtime are absent from the row, verified by name. The door is HTTP-only, answers 404 passkey login is not configured on this server whenever no Passkey method is in auth.methods, refuses to BOOT on a bad rp_id/origin, and has no anonymous registration (invite or admin only). It is the right door in the right plane: holger-server's /-/auth/passkey/* is the DIRECT airgap login (no IdP in a bubble); the browser console's passkey plane is holger-ui-server's, a separate binary on a separate host, and this section keeps the two apart. The bloat is elsewhere, and it is not passkey: the default holger-server binary (server-local, what the systemd unit stages) links the SAME 431-crate closure as holger-full, and 21 of those crates — including the native openssl stack — arrive through the bench edge, not through any declaration. See A.4/A.5.

A.2 What auth-passkey cost — the numbers (MEASURED)

row (recipe flags) crates -e normal delta note
embedded (--no-default-features --features embedded) 370 — floor
mini (… mini,appliance) 385 —
mvp-server WITHOUT passkey (… mvp-server,appliance) 388 — pre-542e296 shape
mvp-server (… mvp-server,appliance,auth-passkey) 392 +4 nornir-auth-webauthn 0.1.2, ciborium 0.2.2, ciborium-io, ciborium-ll
server-local w/o bench, w/o passkey 406 — hypothetical
server-local w/o bench 410 +4 passkey again
server-local (holger-server, default features) 431 +21 via bench see A.5.1
full (… full,front) 431 0 vs server-local identical crate-name set

Binary size and compile time (release, CARGO_INCREMENTAL=0, private target dir, 16 cores):

binary bytes wall s note
holger-mvp without auth-passkey 32 475 776 (31.0 MiB) 519 cold build
holger-mvp with auth-passkey 32 875 720 (31.4 MiB, +399 944 = +1.2 %) 229 warm: the marginal cost of the feature
holger-server default 39 251 408 (37.4 MiB) 152
holger-server without bench BUILD_LOCAL_NOBENCH_BYTES BUILD_LOCAL_NOBENCH_SECS

Forbidden-crate check on mvp-server (MEASURED, by name in the closure): reqwest absent, ureq absent, openssl-sys absent, webauthn-rs absent, wasmtime absent. skidbladnir 0.1.16 IS present in every tier including mvp-server — non-optional, default-features = false, systemd lifecycle only (server/cli/Cargo.toml:84-87), which is what the LAW mandates for a nordisk service; with no features it is the right dependency.

A.3 The door's surface when it is compiled in (READ)

property where value
unconfigured server/lib/src/exposed/http.rs:618-622 settings.passkeys == None ⇒ 404 passkey login is not configured on this server on every route, /config included
listener http.rs:616-617, :1140-1150 HTTP only; the gRPC port 404s every passkey route; the minted token then rides the ordinary bearer seam
rp_id / origin lib.rs:1971-1973 → auth.rs:979-995; passkey.rs:256-267 validated at boot (IP literal refused); a bad one refuses BOOT, not the first ceremony
registration passkey.rs:515-573 two doors: valid single-use invite (24 h, :96) or an admin identity; bootstrap credential retires once an admin passkey exists (:551-563)
body / identity order http.rs:644-666 identity resolved BEFORE the body is read; body capped at 64 KiB
partition http.rs:626-629 transport peer, never a header
session MAC key passkey.rs:268-271, :828-846 persisted <data>/holger-passkeys/session.key, 32 bytes, 0o600, created once
session TTL passkey.rs:91, :478-487 12 h; epoch: 0 hard-coded — no revocation epoch is wired
challenge TTL passkey.rs:68, :366/:399 300 s on Instant
escape hatch refusal text passkey.rs:551-563 deleting <data>/holger-passkeys re-opens bootstrap enrolment — a /data write is an enrolment capability (same trust as the anti-rollback floor, §C.4)

Stale prose: server/lib/src/lib.rs:72 still says the feature is "only the full tier"; the recipe names it on mvp-server and mvp-server-musl (holger-recipe/src/variants.rs).

A.4 The feature-by-accident audit — every holger-server-lib feature, per variant (MEASURED)

Method: cargo tree --offline -p holger <row flags> -e features -i holger-server-lib for every cargo row the recipe declares, then reading each feature's ENABLER lines. NAMED = the row's own feature list (or default for server-local) reaches it through holger's own feature table. INHERITED = reaches it only through a dependency edge that is not a declaration — bench → holger-bench-scenarios (which pins server-lib with ["mini","online","auth-bearer","auth-oidc","auth-oauth2","auth-mtls", "auth-passkey"], bench-scenarios/Cargo.toml:62) or the server-lib = … features = ["sign"] edge at server/cli/Cargo.toml:71. BOTH = named and also arriving by an edge (drop the declaration and the feature stays on). · = off.

server-lib feature embedded mini mvp-server(+musl) full server-local (holger-server)
embedded NAMED · · · ·
mini · NAMED NAMED (via mvp-server) INHERITED (bench) BOTH
full · · · NAMED ·
federation · · · NAMED (via full) ·
auth-bearer via tier via tier via tier via tier via tier + bench
auth-mtls via tier via tier via tier via tier via tier + bench
auth-oidc · · · NAMED (via full) + bench INHERITED (bench)
auth-oauth2 · · · NAMED (via full) + bench INHERITED (bench)
auth-passkey · · NAMED (542e296) NAMED (via full) + bench BOTH (542e296)
auth-webauthn (openssl) · · · · ·
online · · · NAMED BOTH
front · · · NAMED NAMED
sign INHERITED (cli dep edge) INHERITED INHERITED INHERITED INHERITED
testmatrix · · · · ·
wasm-plugins · · · · ·
default (of server-lib) never applies: server/cli/Cargo.toml:71 is default-features = false

Reading the table:

  1. server-local = holger-server = the systemd unit's binary carries the OIDC and OAuth2 providers BY ACCIDENT. Neither default nor any tier names them; they are on because holger-bench-scenarios pins them and cargo unifies per graph. This is exactly the mechanism 6f02ddf fixed for front and 542e296 fixed for auth-passkey, and it has two more passengers nobody wrote down. The one test that guards the modern-auth surface (build-space/tests/auth_ship_small.rs) checks mini, embedded and full, never server-local; the recipe exempts it from the egress judge by declaration (holger-recipe/src/rules.rs:95-102, AIRGAP_TIERS = embedded, mini, mvp-server).
  2. full inherits mini. The "three exclusive tiers" promise holds only in the binary (server/cli/src/main.rs:28-40 compile_error!); server-lib itself is mini+full in every holger-full build, and the manifest comment at main.rs:20-27 knows it. Harmless today because mini = auth-bearer + auth-mtls ⊂ full, but the guard that would notice a future divergence is on the wrong side of the edge.
  3. sign is named by NO variant. Provenance verification of znippy archives rides the server/cli/Cargo.toml:71 dep edge in every row; a row that stopped inheriting it would lose verification silently until a repo sets ron_trusted_roots and wire_verified_znippy refuses at boot (.nornir/promote-sync-attest-design.md:735-744 already says so).
  4. auth-webauthn (the openssl sibling), testmatrix and wasm-plugins are off in every row — the comments claiming so are true.

A.5 Findings the audit surfaced

A.5.1 holger-server links native OpenSSL through a benchmark crate (MEASURED)

cargo tree -p holger -i openssl-sys -e normal (default features): openssl-sys ← native-tls ← hyper-tls ← reqwest 0.12.28 ← holger-bench-scenarios. Cause: bench-scenarios/Cargo.toml:76 declares reqwest = { version = "0.12", features = ["rustls-tls", …] } WITHOUT default-features = false; reqwest's default default-tls = native-tls is unified onto the single reqwest node, so server-lib's own default-features = false, rustls-tls (server/lib/Cargo.toml:121) is overridden for the OIDC /userinfo + JWKS fetch path in the production binary. The 21-crate bench payload in holger-server: bumpalo, encoding_rs, foreign-types, foreign-types-shared, holger-bench-scenarios, hyper-tls, jsonwebtoken, native-tls, nornir-auth-oauth2, nornir-auth-oidc, oauth2, openssl, openssl-macros, openssl-probe, openssl-sys, pem, serde_path_to_error, simple_asn1, tokio-native-tls, zip, zopfli. Two TLS stacks in one server binary; a C library the appliance tier was explicitly built to exclude (server/lib/Cargo.toml auth-webauthn note) is in the local tier by the back door.

A.5.2 holger-server and holger-full are the same 431 crates (MEASURED)

comm -3 over the two closures is empty. The tier difference is feature flags inside holger-server-lib and skidbladnir (federation, upstream-aws, upstream-azure), not linkage. The recipe's own size table (holger-recipe/src/rules.rs:48, "full 32.4 MB reqwest + ureq") should be re-measured against server-local; see the build table in A.2.

A.5.3 agent-online links openssl and TWO reqwest majors (MEASURED)

agent (offline, the recipe's --no-default-features) is clean: 170 crates, no HTTP client. agent-online (256 crates): openssl-sys, native-tls, hyper-tls, reqwest 0.12.28 AND reqwest 0.13.4. Cause: the three connectors (agent/connector/{artifactory,nexus,world}/ Cargo.toml:15/18/18) pin reqwest 0.12 with default features, agent/lib/Cargo.toml:35 pins 0.13 rustls. Not an airgap row, but it is the courier that reaches the outside, and it carries the one TLS stack the house said it would not.

A.5.4 Two passkey planes, FOUR WebAuthn verifiers (READ)

The ruling is two planes. The house has four implementations of the RP-side ceremony: edda nornir-auth-webauthn (webauthn-pure, used by holger-server), and three hand-rolled ports — garmr → gunnar-ui-server (gunnar-ui/crates/gunnar-ui-server/Cargo.toml:108-110 p256+ciborium) → holger-ui-server (holger-ui/crates/holger-ui-server/Cargo.toml:62-72, "a PORT of gunnar-ui-server, which is a port of garmr's"). Two planes is right; four copies of ES256-over-P-256 + CBOR attestation parsing is three verifiers too many, and a bug found in one is fixed in one. This is a share-with-gunnar item (§B.4 row 7).

PARTLY ANSWERED 2026-09-21, agent/w15-w13 — see HOLGER-PLAN.md §4 "W15 — the duplicates collapsed" for the six-copy table with file:line and the linked p256 major. Two of this paragraph's claims were wrong when measured. Four is now two: holger-ui dropped its port on 2026-09-20 (1e4b98c) and gunnar-ui in this lane, both onto nornir-auth-webauthn 0.1.2 — 223 lines out of gunnar-ui alone. What remains is garmr's ANCESTOR copy (garmr/crates/garmr-cli/src/api/passkey.rs:403-560), and it did not move for a measured reason: it links p256 0.14 against the crate's 0.13, so linking the shared crate would put two P-256 implementations in one binary. The cascade that fixes it — nornir-auth-webauthn → 0.2.0 on p256 0.14, then holger-ui, gunnar-ui and holger/server/lib re-pinned — is written up in §4.


B. Ax till limpa with gunnar — what to share, what to keep apart

B.1 The standards are implemented in the house, spec-exact, pure Rust — and holger uses one of them (READ)

standard producer verifier on by default? holger today
DSSE v1 (PAE byte-exact, payload-type confusion defended) znippy/znippy-common/src/sign.rs:1347 dsse_sign :1375 dsse_verify, keyid = SHA-256 cert fingerprint (:687) feature sign uses it: server/lib/src/attest.rs delegates wholly (fa9f5e6)
M-of-N threshold keyed on cert fingerprints sign.rs:1045 sign_multi :1090 verify_threshold, refuses M=0 sign not used; update-bundle has its own two-person rule (§B.3)
in-toto Statement v1 + SLSA Provenance v1 edda/crates/nornir-build-thing/src/provenance.rs:297 to_slsa_statement, :362 slsa_statement_bytes, :419 DsseSealer — always attest.rs re-declares IN_TOTO_STATEMENT_TYPE (attest.rs:95) and asserts byte parity with slsa_statement_bytes (:866)
Sealed-provenance verifier — modgunn/src/provenance.rs:700 verify_sealed_provenance(path, roots, Some(sha256)) modgunn default = ["scan","provenance-verify"] (Cargo.toml:60) linked (server/lib/Cargo.toml:131), never called; promote/mod.rs:123 still hands modgunn blob_sha256: "" via artifact_from_coords — the digest-carrying artifact_from_coords_with_digest (security.rs:202-222) has no caller
VSA (verification summary) nobody nobody — —
TUF nobody nobody — update-bundle has the semantics (§B.3)
Gate seam on the hot path gunnar/crates/gunnar-policy/src/lib.rs:173 pub trait Gate — — DONE. promote::gate::Gate, set-scope, digest-and-bytes required, verdict vocabulary shared through nornir_gate in edda. Three gates today: osv, attested, and provenance (2026-09-21, plan §4 "W17") — the last registered UNCONDITIONALLY, so a rung may name it on any shipped row. Original text: none; promote::PolicyGate (promote/mod.rs:66-125) is a scanner call behind env vars, not a set-scope gate

And gunnar itself does not use DSSE: gunnar-core/src/provenance.rs:12-25 says so — SLSA's field set, sshsig envelope, namespace gunnar-provenance, no in-toto _type, no predicateType. So "gunnar hade DSSE/SLSA" is half true: gunnar has SLSA-shaped provenance with its own signature; the DSSE producer/verifier the house owns lives in znippy and is consumed by nornir (nornir/src/build/seal.rs:86), modgunn and — since today — holger.

B.2 What landed in holger today and what is still unwired (READ)

piece commit state
attest.rs evidence plane keyed by digest, DSSE + in-toto via znippy-common fa9f5e6 1 458 lines, tested, no door (grep attest server/lib/src/exposed/ proto/ server/cli/ = 0), no Holger::attest_store() constructor, no promotion consumer (grep attest server/lib/src/promote/ = 0)
tamper-EVIDENT audit chain, blake3 per record, seeded across segments dc9a457/ca8ea4d/6a52499 keyless by design (audit/mod.rs:27-52); the operator-held --expect-tail IS the checkpoint (server/cli/src/secure_update.rs:158-171)
wasmtime 27 → 48 2552ceb plugin/host/Cargo.toml:32; server/lib/Cargo.toml:458-461 still says 27 / 17 advisories / "bump BEFORE enabling" — stale on all three claims
bounded plugin host: fuel 1e9, epoch, 256 MiB, 100 000 table elements, 30 s, named refusals, .wasm verified by detached CMS over blake3 BEFORE compile b594965 plugin/host/src/limits.rs:52-70, verify.rs:54,:173; wasm-plugins off in every row (A.4)
Gate trait, promotion → attest wiring, VSA emission, RON gate-set section, attest door agent/w17-signature (2026-09-21) MOSTLY DONE — see .nornir/HOLGER-PLAN.md §4 "W17 — a signature that means something". Gate trait + RON gate-set: landed. Promotion → attest wiring: landed as ProvenanceGate, registered on ALL SIX rows that build holger, steered by promotion.provenance: Require | Warn | Off (default Warn, defended in §4), refusing by holger-promote-10 / holger-attest-11. The attest DOOR is still absent and so is the gate-certificate ENROLMENT config key, so AttestStore::accept on a box configured only through the attestation: block still refuses every envelope with holger-attest-4
the trust roots the whole plane verifies against agent/w17-signature (2026-09-21) WAS A SILENT SHORTFALL, now holger-attest-10. MEASURED at bb0302e: AttestTrust::from_der_root_files read each path in attestation.trust_roots and pushed the BYTES — no parse, no emptiness check. An empty file, a stray PEM or a truncated certificate became an anchor that matched nothing; a bad root beside good ones loaded the good ones and dropped it, so an estate verified against fewer roots than it declared while the boot log said the evidence plane was mounted. The rule already existed in plugin/host/src/verify.rs (holger-plugin-8) and attest.rs cited it in prose only. One bad root now refuses the whole declaration. Plan §4 "W17" §1

B.3 Six envelopes and their identities (READ)

envelope where alg key identity verifier
KORPSIG1 edda/crates/korp-installer/src/rollback.rs:105 ed25519 over domain‖version‖blake3 root raw 32-byte pubkey from ESP / cmdline, no keyid yes
holger.update-bundle/v1 holger/update-bundle/src/schema.rs:4 detached ed25519 + X.509 signer certs, single-level chain via x509-parser/ring cert SHA-256 fingerprint → roles (policy.rs:62-72, :225) yes, verify.rs, fixed fail-closed order
gunnar-sshsig-v2 gunnar/crates/gunnar-grpc/src/auth.rs:164 SSHSIG, namespace gunnar-grpc SSH key fingerprint → ACL table yes, 5-step AuthGate
monetize signature-in-object monetize/crates/monetize-product/src/lib.rs:453 ed25519 over canonical JSON minus sig none in object — verifier is handed the key yes
Skidbladnir .sig.json Skidbladnir/src/archive.rs:419 ed25519 over ASCII root_sha256 raw pubkey hex, pinned out-of-band yes (feature pki)
gunnar provenance (gunnar-provenance) gunnar-core/src/provenance.rs:44 SSHSIG SSH key yes
DSSE (attest.rs) — the non-envelope znippy-common/src/sign.rs:1276 CMS-alg from signer cert SPKI cert SHA-256 fingerprint yes

Three key-identity vocabularies (cert fingerprint, SSH fingerprint, raw ed25519 key) and one "no identity". A customer verifying ax-till-limpa must hold three trust stores today. A seventh envelope is refused; the seventh IDENTITY vocabulary is the thing to refuse next.

B.4 The share proposal — one table (DESIGNED)

Home: edda, one new crate nornir-attest (name open), depending on znippy-common/sign for DSSE/CMS and on nornir-build-thing for the Statement types. Publishable on crates.io via dwarves crate-release like the rest. gunnar and holger both depend on it; neither depends on the other.

# link today (owner) proposal stays separate migration cost
1 DSSE envelope, PAE, keyid=cert fingerprint znippy-common (sign.rs) shared as-is; re-export from nornir-attest — 0 for holger; gunnar: none until it emits DSSE
2 in-toto Statement v1 + SLSA v1 types, canonical bytes nornir-build-thing (provenance.rs:297-428) shared; holger's re-declared IN_TOTO_STATEMENT_TYPE (attest.rs:95-116) becomes a re-export; gunnar's BuildRecord (gunnar-core/src/provenance.rs:63) gets to_slsa_statement() (its doc already says "an export to in-toto is a rename") gunnar keeps SSHSIG for its git/grpc doors holger: delete 6 consts, 1 test; gunnar: ~150 lines + a DsseSealer over its ed25519 key
3 Gate trait, Verdict, undecided, PolicyEngine::evaluate, GUN-007 gunnar-policy (lib.rs:173-330) shared trait + engine in nornir-attest::gate; PushContext becomes a generic Subject<'a> (bytes REQUIRED, whole-set) gunnar's five gate impls (protection/refname/limits/signed/push_options) stay in gunnar; holger's (attestation-required, closure, licence) in holger gunnar: 1 crate boundary move, 0 behaviour; holger: writes the impls it has none of today
4 Verdict cache key (blob_sha256, policy_version, advisory_db_rev) modgunn (core.rs:125) shared (already is); holger must call artifact_from_coords_with_digest from promote/mod.rs:123 — 1 call site + the refusal when no digest is available
5 Repository metadata plane (TUF semantics): dual digests, signed manifest, unreferenced/missing refusal, fingerprint roles, two-person, monotonic sequence, verify-before-decompress holger update-bundle stays in holger as the substrate; nornir-attest gets ONLY the TrustPolicy fingerprint vocabulary (policy.rs:62-72) so gunnar's release artifacts can be shipped in a holger bundle without a second policy language gunnar's own ISO/update path (korp-installer, KORPSIG1) none now; a later step maps KORPSIG1's raw key to a fingerprint entry
6 Clock: signed peer:// time, nonce, skew→resync gunnar-clock (zero gunnar deps, publish = false by workspace) shared: flip publish, holger adopts gunnar_clock::fetch::sync + install_skew_handler; front = the time authority for both estates gunnar keeps its front-side serve gunnar: 1 manifest line; holger: §C.2
7 WebAuthn RP ceremony (ES256, CBOR) 4 copies (A.5.4) shared: nornir-auth-webauthn::pure is the one; gunnar-ui-server and holger-ui-server drop their ports session/invite policy per product ~300 lines deleted per UI server; one conformance test each
8 Audit chain checkpoint (signed tail) holger audit (keyless) / gunnar transparency log (gunnar-transparency namespace) shared signer: the DSSE seal from (1) over {tail, segment, count}; gunnar's transparency log is the obvious witness for a holger tail logs stay separate holger: 1 CLI verb + 1 config key; gunnar: 1 RPC
9 VSA emission (https://slsa.dev/verification_summary/v1) nobody shared type in (2); holger emits on promotion, gunnar on merge-to-protected — holger: after (3)

Row 7 LANDED 2026-09-21 (agent/w15-w13): both UI servers now link nornir-auth-webauthn::pure. The "4 copies" reading is corrected in A.5.4 above; the remaining copy is garmr's, held by a p256 major. HOLGER-PLAN.md §4 "W15 — the duplicates collapsed".

Row 6 (the clock) LANDED 2026-09-21 in a different shape than proposed: holger did NOT adopt gunnar_clock::fetch::sync, because gunnar-clock lives in gunnar and edda owns no time crate, so the seam would have cost a new published crate and a cascade. holger got TrustedTime in update-bundle/src/time.rs with gunnar's MESSAGE shape (signed nonce+time from the FRONT, MAX_ROUND_TRIP and NONCE_BYTES unchanged) under its own domain tag holger-time-v1. HOLGER-PLAN.md §4 "W13 — trusted time".

B.5 The chain a customer verifies end to end, link by link (DESIGNED)

  1. Source: commit on gunnar, signed (git sshsig, SignedCommitsGate) — gunnar-policy.
  2. Build definition: nornir-build-thing variant row, materialized argv, pinned toolchain image digest — nornir-build-thing (CargoToolchain::validate refuses an empty digest).
  3. Build provenance: in-toto Statement + SLSA v1, buildType = https://nordisk.dev/nornir/build-thing/variant/<name>, resolvedDependencies = git+nordisk/<repo>@gitCommit — nornir-build-thing to_slsa_statement, sealed by nornir ZnippySealer (nornir/src/build/seal.rs:90) into a DSSE — znippy-common.
  4. Archive: znippy archive root over per-file digests, CMS/DSSE signed, attestation entry inside — znippy-common archive_root.
  5. Scan verdict: modgunn verify_sealed_provenance + CVE/licence, content-addressed Verdict — modgunn.
  6. Admission to holger: AttestStore::accept (binding before crypto, enrolled keyid, DSSE verify) — holger attest.rs, via a DOOR that does not exist yet (§B.2).
  7. Promotion: a Gate set (attestation-required, closure, licence) judges the WHOLE set with bytes required; GUN-007 (unreadable gate config refuses everything); emits a VSA DSSE naming the subject digests and the policy version — holger via nornir-attest::gate.
  8. Courier: holger-update-bundle — payload + evidence (the DSSEs from 3, 5, 7) as members with dual digests, fingerprint-role approvals, two-person rule, sequence — holger update-bundle.
  9. Downstream admission: load_bundle_verified (verify before decompress), check_admissible (sequence), expiry against a clock that is itself signed (§C.2) — holger update-bundle + gunnar-clock.
  10. Boot: appliance ISO by korp-installer, KORPSIG1 root-of-trust, rollback floor — edda korp-installer.

⚠ Link 10's join to the rest was MEASURED 2026-09-21 (plan §4 "W17", ?11 and ?40). KORPSIG1 signs 62 fixed bytes — "korp.root-of-trust.v1\0" ‖ version ‖ blake3 root — while DSSE signs PAE(payloadType, payload) over the whole statement. So the two are separated by their SIGNED MESSAGE, not by their key material: no re-encoding turns a KORPSIG1 signature into a DSSE one, and merging the seeds would not change that. The CLAIM renames cleanly and holger can now speak it (server/lib/src/korpsig.rs, behind the non-default korpsig-export, keyed by the same blake3 root the appliance fails closed on). What closes the link is one X.509 certificate over the boot signer's public key, enrolled in holger's attestation.trust_roots — edda's side of the fence, not holger's.

The customer's verification is: one DSSE trust store (cert fingerprints) for links 3-9, one KORPSIG1 key for link 10, one SSH key for link 1. Three today; the proposal makes links 1 and 10 EXPORT into the fingerprint vocabulary of link 8 rather than replacing them.

B.6 What must stay separate

B.7 Migration cost, honestly (DESIGNED / READ)


C. Appliance without an OS, and survival

C.1 What "appliance utan OS" demands vs what holger does (READ)

demand today gap
one static binary, no libc/shell/distro holger-mvp-appliance: base = scratch, one layer, entrypoint /holger-mvp (holger-recipe/src/packaging.rs:51-70); musl row mvp-server-musl, self-contained rust-lld (.cargo/config.toml) none; getty//bin/sh/init= = 0 hits
installer + live medium holger-installer, holger-appliance-iso rows (variants.rs:236-285), korp-installer kernel is an OPERATOR INPUT (packaging.rs:305-315, no kernel_donor); an appliance whose kernel is whatever --kernel was handed is not reproducible from the recipe — gunnar chose tunnr/tunnr-kernel, holger has not
re-image without brick korp.reinstall keep/wipe (packaging.rs:184-207); default Keep BRICKS when identity is re-minted (measured on gunnar's twin 2026-09-17, quoted there) the default is the brick path; and wipe resets the anti-rollback floor (§C.4) — the parameter's doc mentions the KEK, not the floor
boot_order = "cdrom,disk" prose only (variants.rs:264, toml_parity.rs:120); declared in private-holger-ops a re-image killed mid-install loops the installer (known from UpCloud) — same shape as gunnar's memory
trusted time SystemTime/Utc::now() inline (§C.2) gunnar-clock unadopted
survives expired metadata no policy field exists (§C.3) ratification
survives a lost /data passkey bootstrap re-opens; rollback floor resets; audit chain restarts from genesis seed no witness outside /data
no shell to read a panic panic=10, console=ttyS0 (build-variants.toml:224-234) PID 1 panics are invisible on hosts without a serial console (gunnar's UpCloud lesson)

C.2 SystemTime is trusted for every expiry check — verified (MEASURED by grep, READ)

No trait, no clock struct anywhere in holger. Two seams thread now as a parameter (update-bundle/src/verify.rs:539 verify_member_map(.., now); server/lib/src/retention.rs:417) — good, testable; everything else reads the wall clock at the decision site:

decision site reads
bundle admission: cert windows, expires_at, maximum_bundle_age_secs update-bundle/src/api.rs:31,45; verify.rs:140,495; server/cli/src/secure_update.rs:272 (the appliance's real import) Utc::now()
bundle authoring refuses expired release cert finalize.rs:163 via now
passkey session exp (12 h) server/lib/src/passkey.rs:478-482 now_secs() = SystemTime
passkey invite (24 h) passkey.rs:526,743 SystemTime
passkey challenge (300 s) passkey.rs:366,399 Instant (monotonic — fine)
OCI upload-session reaping (24 h) server/repository/file-oci/src/lib.rs:199,208 SystemTime
retention deletions server/cli/src/main.rs:1627 → retention.rs:457 SystemTime
OIDC state anti-replay, JWKS cache marketplace/signin.rs:76,302 Instant
JWT exp not in this repo — edda nornir-auth-oidc its own SystemTime

An attacker with physical access moves the RTC; every row above then lies in his favour. CertificateValidity::IgnoreUnreliableClock (update-bundle/src/policy.rs:41-60) is the existing answer and it is the wrong one for a product that must survive: it disables the only revocation an airgapped verifier has. gunnar solved this: gunnar-clock — GUNNAR-TIME/1 <nonce> → signed <nonce> <unix_ms> under SSHSIG namespace gunnar-time-v1@gunnar.rs, nonce from the OS RNG per exchange, bounded round trip, key from the kernel cmdline not the ACL table, SNTP REFUSED on the appliance role, skew_refused() triggers a resync at most once a minute, 6 h safety timer, never the internet (gunnar-clock/src/{peer,trigger,config}.rs). Adoption sketch in §E.4: holger's secure_update.rs:272 and passkey.rs:478 take now from a TrustedNow that is gunnar_clock's last verdict, and a Stale verdict makes the bundle import REFUSE BY NAME (holger-update-<n>: clock unverified) rather than read the RTC.

C.3 Expired metadata on a downstream: the field does not exist (READ)

There is no server-side "what to do when metadata expires" knob. OperationMode::ReadOnly exists (server/lib/src/lib.rs:342) but every read_only in server/lib is about repository KIND, not staleness. In the trust policy require_expiry defaults to false (update-bundle/src/policy.rs:124-125) and maximum_bundle_age_secs to None (:120-121), so an un-migrated policy accepts a bundle with no expiry and no age bound — that is the "no expiry: a freeze attack is free and silent" arm of promote-sync-attest-design.md:664. The pattern to copy is trust_mode: Option<TrustMode> with NO default and a named refusal (policy.rs:79-85, holger-update-34). Proposal (§E.3): on_expired_metadata: Option<ExpiredPosture> ∈ {ReadOnly, Refuse}, refusal holger-update-<n> when unset; and require_expiry flipped to default true in the next policy schema version.

C.4 The anti-rollback floor lives only on /data (READ)

RollbackStore = <ron_data_dir>/../holger-update-state/<supplier>/<product>/<seq>.accepted (server/cli/src/secure_update.rs:196-207, update-bundle/src/rollback.rs:107-185). A fresh disk or korp.reinstall=wipe → AcceptedState::empty() (rollback.rs:122-123) → check_admissible admits ANY sequence, including one below the last accepted. No TPM/NVRAM counter, no signed checkpoint. korp-installer already has the primitive: rollback.floor + TPM-witnessed variant (edda/crates/korp-installer/src/rollback.rs:118,264) for the INSTALLER's own floor. The bundle floor should be witnessed the same way (§E.5) — the appliance writes highest_accepted_sequence into the korp floor namespace, which survives a /data wipe by construction.

C.5 The installer pin (READ)

xtask/Cargo.toml:60 korp-installer = { version = "0.1.7", path = "../../edda/crates/ korp-installer" } with the comment "0.1.7, the version edda main carries"; Cargo.lock:9707 resolves 0.1.12. The path wins, ^0.1.7 is satisfied, nothing fails — and the manifest names a version five patches behind what links. Two manifests pin it (xtask + the ISO row's recipe), so rolling holger back cannot escape an installer bug and the number written down is not the number shipped.

C.6 Stale prose that would mislead the next reader (READ)

where says is
server/lib/Cargo.toml:458-461 wasmtime 27, 17 advisories, "bump BEFORE enabling" 48 (plugin/host/Cargo.toml:32); 0 advisories on that copy
deny.toml ignores RUSTSEC-2026-0222, -0269 wasmtime 45.0.3 via edda nornir-build-thing plugin-host DEV-dep; fix is edda's bump; not shipped
deny.toml ignores RUSTSEC-2026-0194, -0195 quick-xml via maven pom parsing CORRECTED 2026-10-29 (lane QXML): DEV-dep only (znippy-plugin-maven in two crates' tests; cargo tree -e normal,build -i quick-xml@0.36.2 is empty), not shipped; fixed in znippy-plugin-maven 0.10.1 (quick-xml 0.41), waiting for its publish
deny.toml ignore RUSTSEC-2023-0071 rsa Marvin reached via TLS/JWT RSA on public keys only; acceptable
server/lib/src/lib.rs:72 passkey "only the full tier" mvp-server, mvp-server-musl, server-local, full
xtask/Cargo.toml:58-60 korp-installer 0.1.7 0.1.12
holger-recipe/src/rules.rs:48 full = 32.4 MB see A.2 table

C.7 Audit chain and the passkey store share one trust assumption (READ)

Both are as strong as /data. The chain is keyless (audit/mod.rs:27-52; "he simply recomputes it", secure_update.rs:158-171), so an attacker with /data rewrites history AND re-opens passkey bootstrap by deleting holger-passkeys (A.3). The one signed thing the appliance already has is its update-bundle release cert chain; B.4 #8 uses the DSSE seal to checkpoint the tail, and gunnar's transparency log to witness it.


D. The five worst findings, ranked

  1. holger-server (the systemd binary) links native OpenSSL and two auth providers by accident — through holger-bench-scenarios' reqwest default features (bench-scenarios/Cargo.toml:76) and its server-lib pin (:62). 21 crates, no declaration names them, no test guards server-local, the egress judge exempts it. A.5.1.
  2. attest.rs is a complete plane with no door and no consumer — HALF CLOSED 2026-09-21, plan §4 "W17 — a signature that means something". The CONSUMER exists: promote::gate::ProvenanceGate is registered on every row that promotes and refuses a promotion whose bytes have no statement filed under the operator-declared root, by name (holger-promote-10), or refuses every hop when no root is declared at all (holger-attest-11). The digest-keyed seam is reached: artifact_from_coords_with_digest is OsvGate's call and PromotedArtifact.digest is a required field. The DOOR is still absent — evidence can be filed only in-process — and so is the enrolment config key. Original text: promotion still calls modgunn with an empty digest (promote/mod.rs:123) so the gate is a name@version lookup while the digest-keyed seam sits unused. B.2.
  3. Every expiry check trusts the RTC; the only alternative in the tree (IgnoreUnreliableClock) disables revocation. gunnar-clock is dependency-clean and unadopted. C.2.
  4. The anti-rollback floor and the passkey bootstrap both reset on a /data wipe, and the korp.reinstall default (Keep) is the documented brick path when identity is re-minted — the operator chooses between a brick and a rollback window, and the parameter doc mentions only the brick. C.4, A.3.
  5. Three key-identity vocabularies and four WebAuthn verifiers across gunnar/holger: a customer verifying ax-till-limpa holds three trust stores; a WebAuthn bug is fixed in one place of four. B.3, A.5.4.

E. Patch sketches (not applied)

E.1 Stop the bench edge from decorating the product (fixes D.1)

# bench-scenarios/Cargo.toml:76
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "blocking", "http2"], optional = true }
# bench-scenarios/Cargo.toml:62 — pin ONLY what the bench drives; the binary declares its own auth
server-lib = { package = "holger-server-lib", version = "0.6.7", path = "../server/lib", optional = true, default-features = false, features = ["mini"] }

and a guard: build-space/tests/auth_ship_small.rs gains server_local_links_only_what_default_names() asserting !tree.contains("openssl-sys") && !tree.contains("nornir-auth-oidc") for cargo tree -p holger -e normal (no flags). Then decide: if holger-server SHOULD offer OIDC/OAuth2, name them in default; the audit says today it does so by accident. Same default-features = false on the three connector reqwest lines (A.5.3).

E.2 sign named on the rows (fixes A.4 #3)

server/cli grows sign = ["server-lib/sign"]; every server row in variants.rs names "sign"; the features = ["sign"] on the dep edge (server/cli/Cargo.toml:71) goes away. cargo tree -e features -i holger-server-lib before/after must be byte-identical per row.

E.3 Expired-metadata posture, no default (fixes C.3)

// update-bundle/src/policy.rs — beside trust_mode
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
pub enum ExpiredPosture { ReadOnly, Refuse }
/// `None` is an unanswered question, refused by name (holger-update-<n>), never a default.
#[serde(default)]
pub on_expired_metadata: Option<ExpiredPosture>,

verify.rs:552 step 0 reads it next to trust_mode()?; secure_update.rs maps ReadOnly → OperationMode::ReadOnly (server/lib/src/lib.rs:342) with the refusal text naming the expiry date, never "signature verification failed".

E.4 Trusted now (fixes D.3)

// server/lib/src/clock.rs (new) — the ONE place the wall clock is read for a decision
pub struct TrustedNow { last: Mutex<Option<gunnar_clock::Report>> }
impl TrustedNow {
    /// Err = `holger-clock-1: no verified time` — callers REFUSE, never fall back to SystemTime.
    pub fn now(&self) -> Result<DateTime<Utc>, ClockError>;
}

Call sites: secure_update.rs:272,620, passkey.rs:478,526,743, file-oci/src/lib.rs:199, main.rs:1627. Boot: gunnar_clock::fetch::sync(&config, Trigger::Boot) with GUNNAR_CLOCK=peer://<front-ip> and GUNNAR_CLOCK_KEY from the kernel cmdline (the ISO row already carries params, build-variants.toml:224-234). install_skew_handler wired from the bundle-import Expired/NotYetValid refusals. gunnar side: publish = true on gunnar-clock.

E.5 Witness the bundle floor outside /data (fixes D.4)

On every Admissibility::New, also write highest_accepted_sequence through korp-installer's floor primitive (rollback.rs:264 check_floor, TPM-witnessed variant when present); on boot, RollbackStore::load takes max(marker files, witnessed floor); korp.reinstall doc gains one sentence: "wipe also resets the update floor unless a witness exists".

E.6 The Gate seam in holger (B.4 #3), shape only

pub trait Gate: Send + Sync {
    fn name(&self) -> &str;
    /// Err = could not decide = REFUSE (gunnar `undecided`), never allow.
    fn check(&self, set: &PromotionSet<'_>) -> Result<Verdict>;
}
pub struct PromotionSet<'a> { pub subjects: &'a [(ArtifactDigest, &'a [u8])], pub attest: &'a AttestStore, pub policy_version: &'a str }

GateSet::from_config(Option<&GateSetConfig>) -> Result<GateSet>: None or unreadable = Err, and PromotionEngine::seal refuses on Err — GUN-007 verbatim. Emit a VSA DSSE via znippy_common::sign::dsse_sign on Allow. The RON key lands once, as gates: Option<…>.

E.7 Delete stale prose (C.6) — server/lib/Cargo.toml:458-461, lib.rs:72,

xtask/Cargo.toml:58-60, rules.rs:48.