Skip to content

Enforce root ownership for internal credential files #880

Description

@sehkone

Enforce root ownership for internal credential files

Context

On an endpoint-enabled bootroot host, five fixed files below the state-recorded secrets directory authenticate the host to OpenBao or configure its dedicated agent: registrar-internal/key.pem, chain.pem, acme-account.json, root-fingerprint, and agent.toml. They must be published as UID 0, GID 0, and mode 0600.

origin/main already applies StagedMode::Policy(KEY_FILE_MODE) (0600) to every one of these files before its atomic rename, and the registrar-internal init E2E already checks that mode. The missing invariant is ownership: the general Destination::bootroot_owned wrappers preserve an existing destination's owner and leave a new destination owned by the invoking process.

Four protected files are published through src/registrar/internal/material.rs::write_atomic. agent.toml has two separate publish paths: publish_internal_files during init/credential publication and write_trust_pair during full-rotation Phase 3 and Phase 6. A full rotation reaches its trust-pair writer when the internal credential set is present; that set identifies a registrar host, so the writer must enforce root:root unconditionally when it is reached and must not add a separate endpoint-enabled conditional. The all-or-none internal set has a sixth member, ca-bundle.pem; it is public trust material and remains under its existing CertGroupPolicy::none() ownership policy. The reference documentation already states the five-file root-owned 0600 contract; the new operator-facing requirement is that an endpoint-enabled host is root-operated.

Scope

Add an opt-in fixed-owner staged-write API whose ownership-establishment step accepts the expected UID and GID, chowns the temporary inode before applying its mode, fsyncing it, and renaming it. Production protected-file call sites pass UID 0 and GID 0. Keep the existing atomic_write and atomic_replace owner-preserving wrappers unchanged; the protected paths must reach the fixed-owner API explicitly.

Use that API at all three protected publication sites: the four-file material writer, init's agent.toml publication, and rotation's agent.toml trust-pair publication. Apply the same root:root policy to .prior snapshot copies of the five protected files and to their restore writes. A root-run replacement of a pre-existing non-root-owned protected file deliberately converges its replacement and any protected snapshot or restore to root:root; do not preserve the old destination owner. Keep ca-bundle.pem, including its snapshot and restore path, on its current ownership policy.

Endpoint-enabled registrar-internal publication is root-only. A process that cannot establish root:root ownership must fail before rename; it must not publish the affected final name or continue with a best-effort, warning-only, or invoking-user-owned result. The resulting error must name both the root-ownership requirement and the file being published. If snapshot capture fails while establishing ownership, leave all final names untouched and remove any partial .prior snapshot.

Document in docs/en/installation.md and docs/ko/installation.md that operators must run endpoint-enabled bootroot init as root and continue to use root for commands that modify that installation. The existing reference table needs no ownership-policy rewrite.

Acceptance criteria

  • A root-run, endpoint-enabled init publishes each of the five protected files as UID 0, GID 0, and mode 0600; mode remains a regression guard rather than new functionality.
  • Every protected replacement path reasserts UID 0 and GID 0 before rename: the four-file material publisher, init's agent.toml writer, and rotation's agent.toml trust-pair writer.
  • A non-root endpoint-enabled publication fails before modifying a protected final name and reports that root ownership is required for the named file. A capture failure leaves no partial .prior snapshot; a failure after a complete snapshot retains the existing all-or-none restoration behavior.
  • The five protected .prior copies are root:root and 0600; ca-bundle.pem remains the sixth all-or-none member but retains its existing policy.
  • The English and Korean installation guides state the root-operated requirement for an endpoint-enabled host.
  • Existing ownership behavior remains unchanged for the general atomic_write/atomic_replace wrappers and the private CA bundle. Service credentials and operator-named outputs keep their prior effective ownership: files the sidecars and step-ca read (the secrets/ tree) remain owned by that tree's owner even under a root-run init, via the containing-directory ownership policy their writers select. (Amended at verification: the original call-site-invariance wording was unsatisfiable together with this issue's own root-run E2E, which asserts exactly that sidecar-tree ownership — see the closing comment.)

Constraints

Do not set ownership after the final name is published. Preserve same-directory staging, atomic rename, restrictive temporary creation, chown-before-chmod-before-fsync ordering, and the current symlink policy. Do not weaken the policy to apply only when privileged: endpoint-enabled publication must explicitly require the authority to create root:root files.

The fixed-owner ownership step must take its expected owner as input. Production passes UID 0 and GID 0; no agent.toml key, environment variable, or non-test public API may change that. A #[cfg(test)]-gated constructor alone may supply the test process's effective UID and GID. It must drive the real staging, chown, and rename path, not a mock or a seam that only records a requested owner.

Running endpoint-enabled init as root also makes its newly created install-tree files root-owned outside the sidecar-read secrets/ tree, so later mutating bootroot commands on that host must run as root. (Amended at verification: writers of sidecar-read files select the containing-directory ownership policy, so a root-run init cannot strand files their readers — the step-ca server, step helper containers, and both OpenBao Agent sidecars, all running as the tree owner's uid — can no longer open; the root-run E2E asserts that tree's ownership. The original "not a change to any writer beyond the three protected publication sites" wording was unsatisfiable together with that E2E and is superseded — see the closing comment.)

Out of scope

Changing the registrar-internal credential lifecycle, OpenBao authentication, certificate or trust rotation policy, service credential ownership, private CA-bundle ownership, endpoint transport, process supervision, or the changelog.

Test plan

  • Add Unix unit tests through the real fixed-owner staging path, using the #[cfg(test)] constructor with the test process's effective UID and GID. Assert MetadataExt::uid() on real published files after a fresh publication and after a replacement; cover that the staged inode is chowned before rename. Do not use a mock that asserts the requested owner.
  • In the negative unit test, explicitly assert the non-root effective-UID precondition, then request UID 0 and assert the kernel chown failure leaves the old destination unmodified. CI runs the unit suite as an unprivileged user; this test is neither root-gated nor #[ignore]d.
  • Cover protected snapshot capture and restore ownership, snapshot cleanup on an ownership failure, and the independent init and rotation agent.toml writer selection.
  • Update scripts/impl/run-registrar-internal-init-e2e.sh to probe sudo -n true before setup and fail with a clear passwordless-sudo prerequisite if it is unavailable. Run endpoint-enabled bootroot init through sudo -n env, preserving BOOTROOT_LANG=en and the invoking user's HOME for Docker client configuration. This deliberately makes the scenario, and scripts/preflight/run-all.sh when it reaches that scenario, unavailable on a machine without passwordless sudo.
  • Reclaim root-owned E2E run output with sudo -n rm -rf on the known per-run root after container teardown, so remove_run_root removes the run root and the existing run-root-survived check remains green. Assert UID 0, GID 0, and mode 0600 for the five protected files in this root-run registrar-internal init E2E.
  • Run the affected Rust tests, ./scripts/check-docs.sh, and scripts/preflight/ci/e2e-matrix.sh; before pushing, run scripts/preflight/run-all.sh.

Dependencies

Part of #790.

Pointers

  • src/fs_util.rs (StagedOwner, publish_staged_blocking, atomic_write, and atomic_replace)
  • src/registrar/internal/material.rs (write_atomic, capture_set, and SetSnapshot::restore)
  • src/commands/init/steps/registrar_internal.rs (publish_internal_files)
  • src/commands/rotate/registrar_internal.rs (write_trust_pair)
  • scripts/impl/run-registrar-internal-init-e2e.sh (ensure_prerequisites, run_bootroot, run_init, remove_run_root, and cleanup)
  • docs/en/installation.md and docs/ko/installation.md (the bootroot-internal registrar-agent section)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions