Skip to content

stage1: admit _stage2 via a signed manifest; merge it into stdin (#2)#15

Closed
HarryR wants to merge 1 commit into
mainfrom
manifest-admission
Closed

stage1: admit _stage2 via a signed manifest; merge it into stdin (#2)#15
HarryR wants to merge 1 commit into
mainfrom
manifest-admission

Conversation

@HarryR

@HarryR HarryR commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Part 2 of the signed-mode malleability fix (lockboot/stage0#2); pairs with lockboot/stage0#5 (which must land first — the chain test boots against the updated stage0).

What

ed25519 signed mode used to pin a release key inline and sign the payload and args separately (sig_url / args_url), so a hostile mirror (metadata trusted; mirrors not) could mix-and-match or roll back validly-signed pieces. Now _stage2.<arch> pins { ed25519 pubkey, manifest_url }; stage1 fetches a signed manifest { url, sha256, args, version }, verifies it against the pinned key, admits the payload by manifest.sha256, and takes argv from manifest.args. One signature binds payload + args; the release still rolls forward by re-signing a manifest.

Merge → stdin. The verified manifest is merged additively into _stage2.<arch> of the received user-data, and that merged doc is what the payload gets on stdin (sha256 mode passes the doc through unchanged). Top-level operator keys pass through, so stage2 sees resolved release-signed pins/args and operator config.

  • metadata: drop sig_url/args_url/args_sig_url; add manifest_url/manifest_sig_url + a Manifest type; url/sha256 are the sha256-mode fields. validate(profile) enforces the per-mode shape. 14 unit tests.
  • stage1/main.rs: admit_payload dispatches by mode; fetch_manifest (manifest_sig_url {sha256}manifest hash, else <manifest_url>.sig); build_stdin_config does the merge. fetch_signed_args removed.
  • deploy: ed25519 mode emits <payload>.manifest.json (+ .sig) and pins { ed25519, manifest_url }; --args fold into the manifest; --version added; --sign-args removed. New ed25519 round-trip test.
  • Makefile: manifests built + signed in the chain recipe; smoke-args now runs in signed mode.

Verification (QEMU, full chain)

  • make test-chain-x86_64 SIGN=1 → stage0 + stage1 both admit via manifest; stage2 runs; stdin shows the merged _stage2.<arch> (pointer + spliced url/sha256/args/version), _stage1 left as a pointer.
  • make test-chain-x86_64 (sha256) → boots; stdin is the raw doc (no merge).
  • make smoke-args-x86_64arg[1]: --smoke, arg[2]: hello world (space preserved) from the signed manifest.
  • Negative (direct harness, byte-flipped stage2 manifest sig) → manifest verification failed → fail-closed, payload never runs (UKI still admitted, proving isolation).
  • Workspace cargo test passes; no warnings.

Deferred (tracked on #2)

  • Anti-rollback enforcement (the version field is carried now; TPM-NV floor later).
  • Domain-separated (namespaced) signatures.

🤖 Generated with Claude Code

…doc (#2)

Companion to the stage0 change. ed25519 signed mode now pins { ed25519 pubkey,
manifest_url } in the (trusted) user-data instead of signing the payload + args
separately. stage1 fetches a signed manifest ({ url, sha256, args, version }),
verifies it against the pinned key, admits the payload by the manifest's exact
sha256, and takes argv from the manifest's args. Binding payload + args under one
signature closes the mix-and-match / downgrade hole a hostile mirror could exploit;
the release still rolls forward by re-signing a new manifest.

The verified manifest is merged additively into _stage2.<arch> of the received
user-data, and THAT merged doc is what the payload gets on stdin (sha256 mode passes
the doc through unchanged); top-level operator keys pass through. So stage2 sees
resolved, release-signed pins/args alongside operator config.

- metadata: drop sig_url/args_url/args_sig_url; add manifest_url/manifest_sig_url plus
  a Manifest type; url/sha256 are the sha256-mode fields. validate(profile) enforces
  the per-mode shape. 14 unit tests.
- stage1/main.rs: admit_payload dispatches by mode; fetch_manifest (manifest_sig_url
  {sha256} -> manifest hash, else <manifest_url>.sig); build_stdin_config merges the
  manifest for stdin. Drop fetch_signed_args.
- deploy: ed25519 mode emits <payload>.manifest.json (+ .sig) and pins { ed25519,
  manifest_url }; args fold into the manifest; --version added; --sign-args removed.
  New ed25519 round-trip test.
- Makefile: build+sign manifests in the chain recipe; smoke-args runs in signed mode.

Verified end-to-end (QEMU): full chain boots in sha256 and ed25519 modes; smoke-args
shows the manifest's args reaching argv; the merged doc appears on stage2's stdin; a
corrupted stage2 manifest signature is rejected (fail-closed, payload never runs).
Workspace tests pass.

Anti-rollback (version enforcement via TPM NV) and domain-separated signatures are
deferred follow-ups on #2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@HarryR HarryR closed this Jul 8, 2026
HarryR added a commit that referenced this pull request Jul 8, 2026
… resolution (#16)

## What

Reworks `_stage2` admission so each per-arch entry is a **discriminated
union**, exactly one of:

- **`payload`** -- admit a binary now: `sha256` pin or `ed25519`-signed
(`sig_url`), with inline `args` or signed remote `args_url`. All the
flexible fields from `main` are kept, just nested under `payload`.
- **`manifest`** -- fetch a signed manifest (itself a `_stage2`
fragment) from `url`, verify its detached signature (`sig_url`, else
`<url>.sig`) against the pinned `ed25519` key, **deep-merge the whole
document into the received user-data at the top level** (manifest wins
on conflict), and re-evaluate the entry.

Resolution is a **loop**: a manifest resolves to a `payload`, or
delegates to a fresh `manifest` (per-hop key delegation), until a
payload is reached. A repeated `(url, sha256)` is a cycle and fails
closed. Every hop is recorded in the arch entry's `resolved_manifests`
(verifier-authoritative, carrying the resolved hash), and the merged doc
is what stage2 receives on stdin.

Binding the payload and its args under **one** manifest signature closes
the mix-and-match / rollback malleability in ed25519 mode
(lockboot/stage0#2), while keeping the existing direct
`url`/`sha256`/`ed25519`/`sig_url`/`args_url` paths intact.

## Changes

- **`metadata`**: `Entry` / `Payload` / `ManifestRef` types
(externally-tagged union via `#[serde(flatten)]`), `Payload::admission`
/ `ManifestRef::validate`. `ManifestRef.sha256` optionally pins the
manifest's own bytes.
- **`stage1`**: the resolution loop (replacing the single admit in
`stage2()`), `deep_merge`, `fetch_manifest`; the merged doc is
serialized to stage2's stdin (sha256-only passes the received bytes
through unchanged).
- **`deploy`**: `--manifest` emits a signed manifest fragment per
payload and pins `{"manifest":{url,ed25519}}`; direct sha256 / ed25519 /
`--sign-args` modes unchanged.
- **`Makefile`**: `MANIFEST=1` chain-test mode. **README** + **JSON
schema** updated.

## Verification

- `cargo test --workspace`: 25 tests (union round-trip incl. the
serde-flatten case, `deep_merge` top-level union + manifest-wins, cycle
detection, `resolved_manifests` provenance, deploy manifest round-trip).
- `make test-chain-x86_64` across all modes under QEMU: sha256, ed25519
detached-sig, **signed manifest (`SIGN=1 MANIFEST=1`)**, signed remote
args, inline args, mirror fallback -- all boot `stage0 -> UKI -> stage1
-> stage2`; the manifest run shows the merged `_stage2` +
`resolved_manifests` on stage2's stdin.
- `lockboot-deploy` exercised end-to-end (create
sha256/ed25519/manifest, validate, modify).

Rebuilt from `main` as a purely additive change; **supersedes #15**
(which had deleted the flexible ed25519 paths).

Pairs with lockboot/stage0 `manifest-resolve`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

1 participant