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:
server-local=holger-server= the systemd unit's binary carries the OIDC and OAuth2 providers BY ACCIDENT. Neitherdefaultnor any tier names them; they are on becauseholger-bench-scenariospins them and cargo unifies per graph. This is exactly the mechanism6f02ddffixed forfrontand542e296fixed forauth-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) checksmini,embeddedandfull, neverserver-local; the recipe exempts it from the egress judge by declaration (holger-recipe/src/rules.rs:95-102,AIRGAP_TIERS= embedded, mini, mvp-server).fullinheritsmini. The "three exclusive tiers" promise holds only in the binary (server/cli/src/main.rs:28-40compile_error!); server-lib itself ismini+fullin everyholger-fullbuild, and the manifest comment atmain.rs:20-27knows it. Harmless today becausemini=auth-bearer + auth-mtls⊂full, but the guard that would notice a future divergence is on the wrong side of the edge.signis named by NO variant. Provenance verification of znippy archives rides theserver/cli/Cargo.toml:71dep edge in every row; a row that stopped inheriting it would lose verification silently until a repo setsron_trusted_rootsandwire_verified_znippyrefuses at boot (.nornir/promote-sync-attest-design.md:735-744already says so).auth-webauthn(the openssl sibling),testmatrixandwasm-pluginsare 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— seeHOLGER-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 ontonornir-auth-webauthn0.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 andholger/server/libre-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 linknornir-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, becausegunnar-clocklives in gunnar and edda owns no time crate, so the seam would have cost a new published crate and a cascade. holger gotTrustedTimeinupdate-bundle/src/time.rswith gunnar's MESSAGE shape (signed nonce+time from the FRONT,MAX_ROUND_TRIPandNONCE_BYTESunchanged) under its own domain tagholger-time-v1.HOLGER-PLAN.md§4 "W13 — trusted time".
B.5 The chain a customer verifies end to end, link by link (DESIGNED)
- Source: commit on gunnar, signed (git sshsig,
SignedCommitsGate) — gunnar-policy. - Build definition:
nornir-build-thingvariant row, materialized argv, pinned toolchain image digest — nornir-build-thing (CargoToolchain::validaterefuses an empty digest). - Build provenance: in-toto Statement + SLSA v1,
buildType = https://nordisk.dev/nornir/build-thing/variant/<name>,resolvedDependencies=git+nordisk/<repo>@gitCommit— nornir-build-thingto_slsa_statement, sealed by nornirZnippySealer(nornir/src/build/seal.rs:90) into a DSSE — znippy-common. - Archive: znippy archive root over per-file digests, CMS/DSSE signed, attestation entry inside — znippy-common
archive_root. - Scan verdict: modgunn
verify_sealed_provenance+ CVE/licence, content-addressedVerdict— modgunn. - 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). - Promotion: a
Gateset (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 vianornir-attest::gate. - 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. - 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. - Boot: appliance ISO by korp-installer,
KORPSIG1root-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
- gunnar's git wire (
receive-pack) and itsPushGateseam (gunnar-wire/src/receive_pack.rs:295) — transport-shaped, not artifact-shaped. - gunnar's SSHSIG credential (
gunnar-sshsig-v2): a credential, not evidence; merging identity vocabularies here would let a build key authenticate a push. - holger's
update-bundlecontainer format and itsRollbackStore: the substrate is holger's product; gunnar ships through it, does not fork it. - The two passkey PLANES: server-direct (
holger-server) vs console BFF (holger-ui-server) — sharing the verifier crate (B.4 #7) does not merge the planes.
B.7 Migration cost, honestly (DESIGNED / READ)
- The expensive item is unchanged: the RON gate-set section.
deny_unknown_fieldsis onHolger(server/lib/src/lib.rs:358), so a top-level key is a one-way door — an older binary refuses a newer config. Three RON emitters + golden tests in private-holger-ops. Mitigation: land the key ONCE, asgates: Option<GateSet>with NO default (thetrust_modepattern,update-bundle/src/policy.rs:79-85), and never a second key. Gateextraction from gunnar:PushContext→ generic subject. gunnar's tests (gunnar-cli/tests/policy_fails_closed.rs) become the shared crate's tests; nothing in gunnar changes behaviour.- gunnar-clock:
publish = falseinherited from the workspace (gunnar/Cargo.toml:117);xtask/tests/publishable.rsrequires an explicit answer per member — a one-line change.
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
holger-server(the systemd binary) links native OpenSSL and two auth providers by accident — throughholger-bench-scenarios'reqwestdefault features (bench-scenarios/Cargo.toml:76) and its server-lib pin (:62). 21 crates, no declaration names them, no test guardsserver-local, the egress judge exempts it. A.5.1.— HALF CLOSED 2026-09-21, plan §4 "W17 — a signature that means something". The CONSUMER exists:attest.rsis a complete plane with no door and no consumerpromote::gate::ProvenanceGateis 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_digestisOsvGate's call andPromotedArtifact.digestis 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 aname@versionlookup while the digest-keyed seam sits unused. B.2.- Every expiry check trusts the RTC; the only alternative in the tree (
IgnoreUnreliableClock) disables revocation. gunnar-clock is dependency-clean and unadopted. C.2. - The anti-rollback floor and the passkey bootstrap both reset on a
/datawipe, and thekorp.reinstalldefault (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. - 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.