Skip to content

Add the re-bootstrap and no-prior-generation trust-generation entry points (#51) - #53

Merged
sehkone merged 2 commits into
mainfrom
sehkone/issue-51
Aug 9, 2026
Merged

Add the re-bootstrap and no-prior-generation trust-generation entry points (#51)#53
sehkone merged 2 commits into
mainfrom
sehkone/issue-51

Conversation

@sehkone

@sehkone sehkone commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Summary

The runtime accept path judges a delivered generation against the active generation's key set, and two host states have no such key set to offer: a host offline across more rotations than the control plane retains (the intermediate keys are pruned) and a host the installer never touched (there is nothing to chain from). This adds the two entry points that serve them, both admitting under the anchors the delivered document itself carries — the installer's seed reached over different channels — relaxing the signature-chain check and only that.

The factoring. Re-bootstrap needs its epoch floor to run between verification and activation, so the private admit sequence is split into a verification-only core that writes nothing into the tree and returns the verified document, its verified epoch and the member bytes; a non-activating pin-comparison helper; and an admit_with_pin that is core → comparison → install_generation. admit is admit_with_pin with no pin. Both empty-tree doors run one gate and one sequence through a seed-admission helper whose package source is deferred (a FnOnce returning Cow), so the tree-state gate stays first on the path that has to read a file for its bytes, and admit_seed_generation still reaches extract_member with a borrow of its caller's slice. admit_seed_generation and replace_generation are behaviourally unchanged; their existing tests pass unchanged.

rebootstrap_generation(root, package, authorization, pin) runs, in order, every refusal before the single write: the authorization value is demanded by the signature; an empty tree is refused as NoActiveGeneration; the asserted last-confirmed epoch must equal the recorded one; the host-side pin marker is read and an unpinned call refused before the container is opened; then the verification-only core; then the pin compared against the member digest through the shared helper; then the floor against the verified epoch as StaleRebootstrap; then install_generation. It does not call admit_with_pin, which would install before the floor ran. RebootstrapAuthorization holds the asserted epoch in a private field behind the single constructor asserting_last_confirmed_epoch, derives exactly Debug, Clone, Copy, and its rustdoc says plainly that it establishes no provenance and is not a capability — only the floor and the out-of-band pin resist a compromised control plane.

The pin marker. require-trust-pin at the tree root is read with symlink_metadata, so any entry there — a dangling symlink included — reads as set and contents are never touched. NotFound and only NotFound means unset; every other I/O failure is fail-closed through ReleaseTrustError::Io. That classification lives in one private pure helper taking the std::io::Result and the resolved path, unit-tested over synthesized errors because no unprivileged filesystem arrangement can reach the branch through the entry point.

bootstrap_from_join_material(root, pin) accepts no caller-supplied generation bytes: it hands the join-material location and the pin to the seed-admission helper, so the gate and the sequence are admit_seed_generation's, not re-derived. layout::JOIN_GENERATION_FILE (join-generation.pkg) and Layout::join_generation_path() are added beside the pin marker's pair, and both files this PR reads are resolved through root-based helpers in the release-trust module. The two doc comments that claimed the runtime resolves the marker through the namespace-based accessor are reworded to name the constant as the single declaration and the two accessors as its resolutions.

Four new ReleaseTrustError variants carry the refusals: RebootstrapEpochMismatch, FingerprintPinRequired, FingerprintPinMismatch, StaleRebootstrap. No dependency added.

Closes #51

Test plan

26 new tests in src/release_trust.rs and src/layout.rs, all minting generations in-test from ephemeral keys over tempfile::tempdir() trees, none requiring root.

Re-bootstrap:

  • A strictly greater epoch succeeds from a stale tree; an equal and a lower one are refused with StaleRebootstrap
  • An empty tree is refused as NoActiveGeneration with the tree untouched, asserted with the marker present and bytes that are not a well-formed container, so the test fails if either the marker read or the container open runs first
  • A mismatched asserted last-confirmed epoch is refused with RebootstrapEpochMismatch and the tree untouched
  • It succeeds where accept_generation fails on the same inputs — a generation signed by a key absent from the stale active set — proving the chain check is what was relaxed
  • A generation whose signature does not verify under a non-revoked anchor it itself carries is refused
  • A document epoch and manifest artifact version disagreement is refused by the verifier's target check, with no epoch-agreement comparison of this module's own
  • A floor refusal leaves the stale generation active, the recorded epoch unchanged, and no new generation directory on disk
  • A refusal at any other step leaves the tree byte-identical, asserted against a before/after snapshot; no test asserts a tree state for a failure inside install_generation
  • A non-matching pin is refused with FingerprintPinMismatch; a matching one succeeds

Core and factoring:

  • The core returns a verified document and epoch while creating, touching and pruning nothing, asserted against a before/after tree snapshot
  • The existing admit_seed_generation and replace_generation tests pass unchanged

Pin marker:

  • Marker set + no pin refuses; marker absent + identical inputs succeeds
  • Marker set + no pin + malformed container still gives the no-pin error, proving the gate precedes the container open
  • Marker set + a wrong pin gives the mismatch refusal, and marker set + the right pin admits — the gate asks only whether a pin was supplied
  • Marker set + an epoch not strictly greater is still refused by the floor, proving the gate is additive
  • Empty and arbitrary marker contents behave identically
  • A dangling symlink at the marker path reads as set
  • The classification helper maps Ok → set, NotFound → unset, and PermissionDenied plus one other kind → Io naming the marker path
  • The no-pin refusal is a distinct variant from the mismatch, asserted by matching the error not its message
  • replace_generation succeeds with the marker present and no pin

Bootstrap:

  • Succeeds from an empty tree reading the join-material location; refused on a non-empty tree; refused on a pin mismatch
  • A pin mismatch leaves the tree empty — no generation directory, no active, no epoch record
  • Succeeds from an empty tree with the marker present and no pin, proving it consults no marker
  • An absent join material on an empty tree is an Io error naming that path
  • A non-empty tree with nothing at the join-material location is ActiveGenerationPresent, not Io
  • A package that does not self-admit is refused with admit_seed_generation's own error
  • An arbitrarily low epoch bootstraps onto an empty tree, proving no floor is synthesized
  • The join material is still present after a successful bootstrap

Layout:

  • For both basenames the namespace-based accessor and the root-based helper resolve to the same path

CI:

  • cargo fmt -- --check --config group_imports=StdExternalCrate
  • cargo clippy --all-targets -- -D warnings
  • cargo clippy --all-targets --features test-support -- -D warnings
  • cargo test (422 passed)
  • cargo test --features test-support (422 passed)

sehkone added 2 commits August 9, 2026 17:49
The runtime accept path judges a delivered generation against the active
one's key set, and two host states have no such key set to offer. A host
offline across more rotations than the control plane retains cannot be
chained forward at all — the intermediate keys are pruned — and a host
the installer never touched carries nothing to chain from. Both are
admitted under the anchors the delivered document itself carries, which
is the installer's seed reached over a different channel.

Re-bootstrap needs an epoch floor between verification and activation,
so the admission sequence is split: a verification-only core that writes
nothing, a non-activating pin comparison, and an admit-with-pin that is
the ordinary order of the two plus the installer. Re-bootstrap composes
the same pieces with its floor in the slot the activating form would
have closed. Both empty-tree doors run one gate and one sequence, with
the package source deferred so the gate stays first on the path that has
to read a file to get its bytes.

The pin marker and the join material are read through root-based helpers
joining the basenames layout declares, since a function handed a
resolved tree root cannot reconstruct a namespace to ask a Layout with.

Closes #51
The marker tests are a matrix with one cell missing: every case pairing
the marker with a supplied pin was untested, so nothing held that the
gate asks only whether a pin was supplied. A wrong pin on a marked host
must still be the mismatch refusal rather than the no-pin one, and the
right pin must admit — the configuration the marker exists for.

Part of #51
@sehkone

sehkone commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 1] No blocking findings. This is the right shape for #51: rebootstrap_generation keeps the epoch equality check, marker gate, self-admission, pin comparison, and verified-epoch floor ahead of install_generation, so each new policy refusal preserves the stale generation. The split around verify_self_admitted avoids the dangerous activate-then-check ordering, while admit_onto_empty_tree correctly defers join-material I/O until after the existing empty-tree gate. The tests exercise the essential negative paths, including an empty tree with malformed bytes and a marker, a floor rejection with an exact tree snapshot, self-admission failure, the marker/pin matrix, dangling markers, and the non-empty bootstrap path with an absent join file. The root-based layout helpers and marker documentation also match the issue requirements.

@sehkone

sehkone commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 1: APPROVED]

@sehkone

sehkone commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Suggested squash commit

Title

Add the two self-admitting trust-generation doors

Body

The runtime accept path judges a delivered generation against the active
one's key set, and two host states have no such key set to offer. A host
offline across more rotations than the control plane retains cannot be
chained forward at all — the intermediate keys are pruned — and a host
the installer never touched carries nothing to chain from. Both are
admitted under the anchors the delivered document itself carries, which
is the installer's seed reached over a different channel.

Re-bootstrap needs an epoch floor between verification and activation,
so the admission sequence is split: a verification-only core that writes
nothing, a non-activating pin comparison, and an admit-with-pin that is
the ordinary order of the two plus the installer. Re-bootstrap composes
the same pieces with its floor in the slot the activating form would
have closed. Both empty-tree doors run one gate and one sequence, with
the package source deferred so the gate stays first on the path that has
to read a file to get its bytes.

The pin marker and the join material are read through root-based helpers
joining the basenames layout declares, since a function handed a
resolved tree root cannot reconstruct a namespace to ask a Layout with.

The marker gate asks only whether a pin was supplied, not whether it
matches: a wrong pin on a marked host is still the mismatch refusal
rather than the no-pin one, and the right pin admits.

Closes #51

@sehkone
sehkone merged commit 29ac55d into main Aug 9, 2026
4 checks passed
@sehkone
sehkone deleted the sehkone/issue-51 branch August 9, 2026 09:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add the re-bootstrap and no-prior-generation trust-generation entry points

1 participant