diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f7869bc --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,91 @@ + +# Contributor Covenant 3.0 Code of Conduct + +## Our Pledge + +We pledge to make our community welcoming, safe, and equitable for all. + +We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics, neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or religion, national or social origin, socio-economic position, level of education, or other status. The same privileges of participation are extended to everyone who participates in good faith and in accordance with this Covenant. + +## Encouraged Behaviors + +While acknowledging differences in social norms, we all strive to meet our community's expectations for positive behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture, background, or native language. + +With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared values, including: + +1. Respecting the **purpose of our community**, our activities, and our ways of gathering. +2. Engaging **kindly and honestly** with others. +3. Respecting **different viewpoints** and experiences. +4. **Taking responsibility** for our actions and contributions. +5. Gracefully giving and accepting **constructive feedback**. +6. Committing to **repairing harm** when it occurs. +7. Behaving in other ways that promote and sustain the **well-being of our community**. + + +## Restricted Behaviors + +We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are violations of this Code of Conduct. + +1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any clear request to stop. +2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of people. +3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable identities or traits. +4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or purpose of the community. +5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their permission. +6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group. +7. Behaving in other ways that **threaten the well-being** of our community. + +### Other Restrictions + +1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade enforcement actions. +2. **Failing to credit sources.** Not properly crediting the sources of content you contribute. +3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the community. +4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other restricted behaviors. + + +## Reporting an Issue + +Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help avoid conflicts and minimize harm. + +When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email .** + +Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon resolution. + + +## Addressing and Repairing Harm + +**** + +If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be skipped. + +1) Warning + 1) Event: A violation involving a single incident or series of incidents. + 2) Consequence: A private, written warning from the Community Moderators. + 3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking clarification on expectations. +2) Temporarily Limited Activities + 1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a more serious violation. + 2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the seriousness of the situation and give the community members involved time to process the incident. The cooldown period may be limited to particular communication channels or interactions with particular community members. + 3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and impact, and being thoughtful about re-entering community spaces after the period is over. +3) Temporary Suspension + 1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a single serious violation. + 2) Consequence: A private written warning with conditions for return from suspension. In general, temporary suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions. + 3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for return, and being thoughtful about how to reintegrate with the community when the suspension is lifted. +4) Permanent Ban + 1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or a violation so serious that the Community Moderators determine there is no way to keep the community safe with this person as a member. + 2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working through other remedies has failed to change the behavior. + 3) Repair: There is no possible repair in cases of this severity. + +This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their discretion and judgment, in keeping with the best interests of our community. + + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public or other spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/). + +Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy of this license, visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/) + +For answers to common questions about Contributor Covenant, see the FAQ at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional enforcement and community guideline resources can be found at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion). diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 129708e..eb64a45 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -24,10 +24,10 @@ Preferred channels (either is fine): 1. **GitHub private advisory (recommended).** Open a report at - https://github.com/beardedeagle/postmaster/security/advisories/new + [security advisories](https://github.com/beardedeagle/postmaster/security/advisories/new). This keeps the discussion private and allows coordinated disclosure. -2. **Email.** Write to randy@heroictek.com. +2. **Email.** Write to . Please include as much of the following as you can: diff --git a/.github/actions/setup-nix/action.yml b/.github/actions/setup-nix/action.yml new file mode 100644 index 0000000..8641457 --- /dev/null +++ b/.github/actions/setup-nix/action.yml @@ -0,0 +1,55 @@ +name: Setup Nix +description: >- + Install Nix via the official installer, integrity-verified against a + pinned sha256 before execution, enable flakes, and put nix on PATH for + later steps. In-repo composite: no third-party actions, no unverified + remote code. + +inputs: + installer-version: + description: Nix release version to install. + required: false + default: "2.34.7" + installer-sha256: + description: sha256 of the official install script for installer-version. + required: false + default: "e9d447ce3d2ff62d7ff9cb6ef401de6fa8acb148839dd00f7271945d7b638b14" + +runs: + using: composite + steps: + - name: Install Nix (integrity-verified) + shell: bash + env: + NIX_VERSION: ${{ inputs.installer-version }} + INSTALLER_SHA256: ${{ inputs.installer-sha256 }} + run: | + set -euo pipefail + if command -v nix >/dev/null 2>&1; then + echo "nix already available: $(command -v nix) -> $(nix --version)" + else + url="https://releases.nixos.org/nix/nix-${NIX_VERSION}/install" + tmp="$(mktemp -t nix-install)" + # Verify the pinned digest BEFORE executing: a substituted or + # corrupted installer fails closed here, never runs. + curl -sSfL "$url" -o "$tmp" + if command -v sha256sum >/dev/null 2>&1; then + echo "${INSTALLER_SHA256} ${tmp}" | sha256sum -c - + else + echo "${INSTALLER_SHA256} ${tmp}" | shasum -a 256 -c - + fi + sh "$tmp" --daemon --yes --no-modify-profile + fi + # The installer does not put nix on PATH for subsequent GHA steps. + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH" + fi + + - name: Enable nix-command and flakes + shell: bash + run: | + set -euo pipefail + mkdir -p "$HOME/.config/nix" + if ! grep -q 'experimental-features' "$HOME/.config/nix/nix.conf" 2>/dev/null; then + echo 'experimental-features = nix-command flakes' >> "$HOME/.config/nix/nix.conf" + fi diff --git a/.github/actions/setup-rust/action.yml b/.github/actions/setup-rust/action.yml new file mode 100644 index 0000000..5aa7b49 --- /dev/null +++ b/.github/actions/setup-rust/action.yml @@ -0,0 +1,107 @@ +name: Setup Rust and just +description: Install the pinned Rust toolchain (from rust-toolchain.toml), just, and optional cargo tools. + +inputs: + targets: + description: Optional space-separated list of additional Rust targets to install. + required: false + default: "" + install-just: + description: Whether to install the pinned just command runner. + required: false + default: "true" + just-version: + description: just runner version to install (not the Rust compiler version). + required: false + default: "1.51.0" + install-cargo-deny: + description: Whether to install cargo-deny. + required: false + default: "false" + install-cargo-audit: + description: Whether to install cargo-audit. + required: false + default: "false" + install-target-dir: + description: Cargo target directory for tool installation builds. + required: false + default: target/cargo-install + +runs: + using: composite + steps: + - name: Install pinned Rust toolchain + shell: bash + env: + RUST_TARGETS: ${{ inputs.targets }} + run: | + set -euo pipefail + if ! command -v rustup >/dev/null 2>&1; then + echo "rustup must be preinstalled; refusing to run an unpinned remote installer" >&2 + exit 1 + fi + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + # Installs the toolchain and components pinned in rust-toolchain.toml. + rustup show + if [[ -n "$RUST_TARGETS" ]]; then + rustup target add $RUST_TARGETS + fi + + - name: Install just + if: ${{ inputs.install-just == 'true' }} + shell: bash + env: + JUST_VERSION: ${{ inputs.just-version }} + CARGO_INSTALL_TARGET_DIR: ${{ inputs.install-target-dir }} + run: | + set -euo pipefail + # Probe by actually running `just`, not `command -v just`: a mise/asdf + # shim on PATH satisfies `command -v` but fails at call time with + # "No version is set for shim: just" when no version is pinned. If it + # does not run, or runs but reports a version other than the pin, install + # the pinned just and prepend ~/.cargo/bin so the real binary shadows + # any lingering shim for the rest of the job. + install_pinned_just() { + cargo install just --version "$JUST_VERSION" --locked --target-dir "$CARGO_INSTALL_TARGET_DIR" + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + fi + "$HOME/.cargo/bin/just" --version + } + if found="$(just --version 2>/dev/null)"; then + # `just --version` reports "just X.Y.Z" + found_version="${found#just }" + if [[ "$found_version" == "$JUST_VERSION" ]]; then + echo "just already available at pinned version: $(command -v just) -> $found" + else + echo "just version mismatch: found '$found', pinned is 'just $JUST_VERSION'; installing pinned version" + install_pinned_just + fi + else + echo "no runnable just found; installing pinned just $JUST_VERSION" + install_pinned_just + fi + + - name: Install cargo-deny + if: ${{ inputs.install-cargo-deny == 'true' }} + shell: bash + env: + CARGO_INSTALL_TARGET_DIR: ${{ inputs.install-target-dir }} + run: | + set -euo pipefail + if ! command -v cargo-deny >/dev/null 2>&1; then + cargo install cargo-deny --locked --target-dir "$CARGO_INSTALL_TARGET_DIR" + fi + + - name: Install cargo-audit + if: ${{ inputs.install-cargo-audit == 'true' }} + shell: bash + env: + CARGO_INSTALL_TARGET_DIR: ${{ inputs.install-target-dir }} + run: | + set -euo pipefail + if ! command -v cargo-audit >/dev/null 2>&1; then + cargo install cargo-audit --locked --target-dir "$CARGO_INSTALL_TARGET_DIR" + fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d52b976..53acb5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,29 +32,15 @@ jobs: - macos-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Cache Cargo - uses: Swatinem/rust-cache@v2 - - - name: Check formatting - run: cargo fmt --all --check + - name: Setup Rust and just + uses: ./.github/actions/setup-rust - - name: Run Clippy - run: cargo clippy --all-targets -- -D warnings - - - name: Build - run: cargo build --locked - - - name: Test - run: cargo test --locked + - name: Quality gate (fmt, clippy, build, test) + run: just ci-rust deny: name: Dependency policy @@ -63,11 +49,14 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - - name: Dependency policy (cargo-deny) - uses: EmbarkStudios/cargo-deny-action@v2 + - name: Setup Rust, just, and cargo-deny + uses: ./.github/actions/setup-rust with: - manifest-path: Cargo.toml + install-cargo-deny: "true" + + - name: Dependency policy + run: just deny diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 3bb9c88..2dc71a0 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,15 +29,15 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v16 - with: - extra-conf: | - experimental-features = nix-command flakes + uses: ./.github/actions/setup-nix + + - name: Install just + uses: ./.github/actions/setup-rust - name: Build book - run: nix run nixpkgs#mdbook -- build docs + run: just docs diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 2adf045..df9566e 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -29,18 +29,21 @@ jobs: - macos-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v16 + uses: ./.github/actions/setup-nix + + - name: Install just + uses: ./.github/actions/setup-rust with: - extra-conf: | - experimental-features = nix-command flakes + install-cargo-deny: "false" + install-cargo-audit: "false" - name: nix flake check - run: nix flake check + run: just flake-check - name: nix flake check (eval-only, all systems) - run: nix flake check --all-systems --no-build + run: just flake-check-eval-all diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index a878eee..9ac1e32 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -30,27 +30,17 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - - name: Setup Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo-audit - uses: actions/cache@v5 + - name: Setup Rust, just, and cargo-audit + uses: ./.github/actions/setup-rust with: - path: ~/.cargo/bin/cargo-audit - key: cargo-audit-0.20 - - - name: Install cargo-audit - run: | - if ! command -v cargo-audit &> /dev/null; then - cargo install cargo-audit --locked - fi + install-cargo-audit: "true" - - name: Run cargo-audit - run: cargo audit --deny warnings + - name: Security audit + run: just audit deny: name: Dependency Check @@ -59,11 +49,14 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - - name: Dependency policy (cargo-deny) - uses: EmbarkStudios/cargo-deny-action@v2 + - name: Setup Rust, just, and cargo-deny + uses: ./.github/actions/setup-rust with: - manifest-path: Cargo.toml + install-cargo-deny: "true" + + - name: Dependency policy + run: just deny diff --git a/.gitignore b/.gitignore index 2119da7..5f8189b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ result-* # mdBook build output (docs/) /docs/book/ +# generated by the docs recipe from /CHANGELOG.md +docs/src/changelog.md + # Additional ignores .omc/ target/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6619275..00c8344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,57 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Remediated all 33 findings of the 2026-08-02 security audit (5 HIGH, + 11 MEDIUM, 17 LOW/INFO — 12 fixed, 4 documented as accepted). +- Bundle entries are now name-bound: a ciphertext decrypts only under its + own entry name (AES-GCM AAD); relocating it fails authentication. + Suite bumped to `ecies-secp256k1-hkdf-sha256-aes-256-gcm-entry-aad`; + pre-AAD bundles are rejected. AAD support comes from the additive + `encrypt_with_aad`/`decrypt_with_aad` on the ecies fork (upstream PR + ecies/rs#159), consumed via a git+rev `[patch.crates-io]` pin. +- Hardening: a sibling launch monitor now owns stop semantics on the + exec path — the launcher forks after zeroization, the parent execs the + service (MainPID unchanged), and the fork-child supervises it: + synchronous stop-signal forwarding with a fixed 250 ms grace before + SIGKILL escalation, pidfd/kqueue liveness, and plaintext-file cleanup + on EVERY service-death path (natural exit, `kill -9`, crash) — the + macOS termination-signal deferral across the plaintext-write window + remains, while the sigaction flag-handler and second pre-exec gate are + retired (the monitor subsumes the race; fork(2) on Linux is the one + libc FFI call there, review-gated); logged hardening failures; + fd-pinned secret-file writes and fd-relative prune/cleanup; + `O_NOFOLLOW` ciphertext opens; sentinel-guarded bulk cleanup + (`.postmaster-managed`); keychain reads via the Security.framework API + (no `/usr/bin/security`); `--verify-keys` fails on ANY undecryptable + value, including under `strict:false`; daemon config rejects unknown + fields (root and nested); Linux `id` resolved from verified absolute + paths only; setup verification reuses the runtime key-variable + predicate; bundle `encoding: "utf8"` is validated at resolution. + +### Changed + +- Module restructure per file-size limits (500/750/1000 LOC bands): + `keys/`, `cli/`, `adapter/` domains; conformance suite split under + `tests/exec_conformance/`. +- Dependencies: base64 0.23 plus the minor/patch set (libc 0.2.189, + serde family 1.0.229, serde_json 1.0.151, syn 3.0.3). +- CI automation conforms to the repo doctrine: in-repo composites only + (setup-rust, setup-nix — the latter verifies the pinned installer's + sha256 before executing), actions pinned by sha, workflows consume + just recipes. + +### Added + +- "Unsafe Code" documentation section: exception policy and a registry + of every unsafe site (signal deferral, macOS libc boundary, systemd fd + ownership, test-only env mutation). +- `multiple_crate_versions` clippy lint plus `[bans] multiple-versions` + in deny.toml (syn skipped for the wasm-gated duplicate). + +## [0.1.0] - 2026-07-28 + ### Added - Standalone postmaster binary — a format-neutral local secret diff --git a/Cargo.lock b/Cargo.lock index 2cdd0fc..454b39f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,15 +45,15 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -92,6 +92,22 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -168,8 +184,7 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "ecies" version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a2df85e2b13a5f7fa61e7f0ee776acfe74da80bf2fb1bcde2725af19c197b33" +source = "git+https://github.com/beardedeagle/rs.git?rev=81e1b84#81e1b840d99f2787b5c208f8172a496be304daf5" dependencies = [ "aes-gcm", "getrandom", @@ -223,21 +238,21 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-task", @@ -353,9 +368,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -374,9 +389,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "once_cell" @@ -437,9 +452,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" [[package]] name = "postmaster" @@ -451,6 +466,7 @@ dependencies = [ "hex", "libc", "rustix", + "security-framework", "serde", "serde_json", "zeroize", @@ -458,18 +474,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -507,9 +523,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "scopeguard" @@ -530,11 +546,34 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -542,29 +581,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -604,9 +643,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -679,7 +729,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -715,6 +765,6 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index f54c74d..7e624c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,11 +13,19 @@ repository = "https://github.com/beardedeagle/postmaster" # We use a fairly strict set of lints. One deliberate deviation: we allow # `unsafe_code` (some stricter projects deny it outright). postmaster is a -# syscall-level socket-activation daemon and needs a handful of justified -# `unsafe` blocks (getpeereid(2), best-effort mlock()-style hardening, and -# UnixListener::from_raw_fd on the one inherited systemd fd) — each site in -# src/main.rs carries a SAFETY comment, so `unsafe_code` is left at rustc's -# default (allow) rather than denied or blanket-warned. +# syscall-boundary daemon and needs a handful of justified `unsafe` blocks: +# getpeereid(2) and PT_DENY_ATTACH on macOS (Darwin's stable kernel ABI is +# libc), signal-mask deferral around the plaintext-write window and the +# sibling launch monitor (fork + pidfd/kqueue liveness — see +# docs/src/unsafe-code/monitor.md; on Linux the monitor's signal ops use +# rustix's linux_raw `runtime` API, fork(2) is the single libc FFI call), +# UnixListener::from_raw_fd on the inherited systemd sockets (Linux), and +# test-only set_var/remove_var (unsafe on edition 2024). Each site carries +# a SAFETY comment and is documented in docs/src/unsafe-code/ (the mdbook +# "Unsafe Code" section). mlockall/setrlimit/set_dumpable hardening uses +# SAFE rustix wrappers and is not part of the unsafe inventory. So +# `unsafe_code` stays at rustc's default (allow) rather than denied or +# blanket-warned. [lints.rust] future_incompatible = { level = "deny", priority = -1 } rust_2018_idioms = { level = "warn", priority = -1 } @@ -28,12 +36,14 @@ explicit_outlives_requirements = "warn" if_let_rescope = "warn" impl_trait_overcaptures = "warn" impl_trait_redundant_captures = "warn" -# `let _ = …` is postmaster's deliberate best-effort idiom: hardening calls -# (mlockall-style, non-dumpable, core limits, ptrace-deny) and cleanup paths -# (socket shutdown, stale-socket unlink, thread join at teardown) may -# legitimately fail, and the adjacent comments explain why that's acceptable. -# Converting them to unwrap/expect/error handling would CHANGE behavior in a -# fail-closed daemon, so the lint stays off. +# `let _ = …` is postmaster's deliberate best-effort idiom for cleanup +# paths (socket shutdown, stale-socket unlink, read-timeout set, thread +# join at teardown) that may legitimately fail, and the adjacent comments +# explain why that's acceptable. Security-relevant hardening calls +# (mlockall-style, non-dumpable, core limits, ptrace-deny) LOG their +# failures instead (audit 2026-08-02 A10). Converting either to +# unwrap/expect/error handling would CHANGE behavior in a fail-closed +# daemon, so the lint stays off. let_underscore_drop = "allow" macro_use_extern_crate = "warn" meta_variable_misuse = "warn" @@ -70,6 +80,7 @@ fallible_impl_from = "warn" inefficient_to_string = "warn" macro_use_imports = "warn" match_same_arms = "warn" +multiple_crate_versions = "warn" no_effect_underscore_binding = "warn" panic = "warn" print_stderr = "warn" @@ -92,7 +103,7 @@ ecies = { version = "0.2.11", default-features = false, features = ["pure", "sec dotenvy = "0.15" serde = { version = "1", features = ["derive"] } serde_json = "1" -base64 = "0.22" +base64 = "0.23" hex = "0.4" zeroize = "1" # Syscall layer: rustix's default linux_raw backend issues raw syscalls from @@ -100,11 +111,36 @@ zeroize = "1" # single fd-ownership assertion at the systemd socket-activation boundary. rustix = { version = "1", features = ["net", "process", "mm", "fs"] } +# AAD support for the bundle adapter (audit 2026-08-02 A7) lives on the +# `aad-support` branch of the author's ecies fork pending the upstream PR +# against ecies/rs. Pinned by rev for reproducibility; drop this section +# once the PR merges and ships in a crates.io release. +[patch.crates-io] +ecies = { git = "https://github.com/beardedeagle/rs.git", rev = "81e1b84" } + +[target.'cfg(target_os = "linux")'.dependencies] +# Linux-only additions for the sibling launch monitor +# (docs/src/unsafe-code/monitor.md): +# - rustix "runtime": the monitor's signal ops (mask for life, synchronous +# consumption) use kernel_sigprocmask/kernel_sigpending/kernel_sigwait — +# rustix's experimental, doc-hidden runtime API — so the Linux leg stays +# on linux_raw with NO libc signal FFI. Cargo.lock pins the exact rustix; +# an update that changes this unstable API fails the build loudly, +# never silently. +# - rustix "event": poll(2) on the service pidfd for liveness. +# - libc: fork(2) ONLY. rustix deliberately has no fork wrapper; this is +# the single libc FFI call on the Linux exec path (house-doctrine +# exception, review-gated — flagged in the unsafe-code registry). +rustix = { version = "1", features = ["runtime", "event"] } +libc = "0.2" + [target.'cfg(target_os = "macos")'.dependencies] # Darwin's stable kernel ABI *is* libc (raw syscalls are unsupported there; -# rustix itself calls through libc on darwin). Two narrow calls use it -# directly: getpeereid for the peer check and PT_DENY_ATTACH hardening. +# rustix itself calls through libc on darwin). Narrow direct uses: getpeereid +# for the peer check, PT_DENY_ATTACH hardening, and the sibling monitor's +# kqueue/signal-mask ops (docs/src/unsafe-code/monitor.md). libc = "0.2" +security-framework = "3.7.0" [profile.release] # Everything here is stable-toolchain and arch-agnostic: safe under nixpkgs @@ -114,6 +150,11 @@ opt-level = 3 lto = "fat" codegen-units = 1 strip = "symbols" +# PT_DENY_ATTACH (macOS hardening) is gated on not(debug_assertions); pin +# this so a profile override can't silently compile it out (audit +# 2026-08-02 A23). This is already the release default — explicitness is +# the guard. +debug-assertions = false # Arithmetic bugs become panic → abort → Restart=on-failure: fail closed, # never a wrong answer. Cost is ~nil (crypto deps use wrapping intrinsics). overflow-checks = true diff --git a/Justfile b/Justfile index 5c70074..080dd80 100644 --- a/Justfile +++ b/Justfile @@ -31,15 +31,28 @@ test: flake-check: nix flake check +# Eval-only flake check across all supported systems (no builds) +flake-check-eval-all: + nix flake check --all-systems --no-build + # ============================================================================= # Docs # ============================================================================= docs: + #!/usr/bin/env bash + set -euo pipefail + cp CHANGELOG.md docs/src/changelog.md mdbook build docs + rm -f docs/src/changelog.md + echo "Documentation built: docs/book/" docs-serve: - cd docs && mdbook serve + #!/usr/bin/env bash + set -euo pipefail + cp CHANGELOG.md docs/src/changelog.md + trap 'rm -f docs/src/changelog.md' EXIT + (cd docs && mdbook serve --open) # ============================================================================= # Dependency policy @@ -57,3 +70,173 @@ audit: ci-rust: fmt-check clippy build test ci: ci-rust flake-check + +# ============================================================================= +# Build Matrix — Cross-platform builds with varying optimization levels +# ============================================================================= +# +# Optimization Levels: +# debug - Fast compile, no optimizations, debug symbols (for development) +# release - Cargo defaults (opt-level=3, no LTO) +# optimized - Thin LTO + single codegen unit (good balance of size/speed) +# max - Fat LTO + strip + CPU targeting (smallest, fastest binary) +# +# Targets: +# native - Current platform +# linux - x86_64-unknown-linux-gnu +# linux-musl - x86_64-unknown-linux-musl (static binary, no glibc dependency) +# linux-arm - aarch64-unknown-linux-gnu (ARM64 Linux: Graviton, Pi 4/5) +# macos - x86_64-apple-darwin (Intel Mac) +# macos-arm - aarch64-apple-darwin (Apple Silicon: M1 through M5) +# +# Usage: +# just build-matrix optimized linux # Build optimized for Linux +# just build-matrix max macos-arm # Max optimization for Apple Silicon +# just build-matrix release # Release build for current platform +# just build-all optimized # Build all platforms at optimization level +# ============================================================================= + +# Build with specific optimization level, optional target +build-matrix level target="native": + #!/usr/bin/env bash + set -euo pipefail + + LEVEL="{{level}}" + TARGET="{{target}}" + + # Validate optimization level + case "$LEVEL" in + debug|release|optimized|max) ;; + *) + echo "Error: Invalid optimization level '$LEVEL'" + echo "Valid levels: debug, release, optimized, max" + exit 1 + ;; + esac + + # Map target alias to Rust target triple + case "$TARGET" in + native) RUST_TARGET="" ;; + linux) RUST_TARGET="x86_64-unknown-linux-gnu" ;; + linux-musl) RUST_TARGET="x86_64-unknown-linux-musl" ;; + linux-arm) RUST_TARGET="aarch64-unknown-linux-gnu" ;; + macos) RUST_TARGET="x86_64-apple-darwin" ;; + macos-arm) RUST_TARGET="aarch64-apple-darwin" ;; + *) + echo "Error: Invalid target '$TARGET'" + echo "Valid targets: native, linux, linux-musl, linux-arm, macos, macos-arm" + exit 1 + ;; + esac + + # Set optimization flags based on level + case "$LEVEL" in + debug) + CARGO_ARGS="" + export CARGO_PROFILE_DEV_OPT_LEVEL=0 + echo "Building: debug (fast compile, no optimizations)" + ;; + release) + CARGO_ARGS="--release" + echo "Building: release (Cargo defaults)" + ;; + optimized) + CARGO_ARGS="--release" + export CARGO_PROFILE_RELEASE_LTO=thin + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 + echo "Building: optimized (LTO=thin, codegen-units=1)" + ;; + max) + CARGO_ARGS="--release" + export CARGO_PROFILE_RELEASE_LTO=fat + export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 + export CARGO_PROFILE_RELEASE_OPT_LEVEL=3 + export CARGO_PROFILE_RELEASE_STRIP=symbols + # CPU targeting: `native` describes the build HOST, so only apply + # it when the target IS the host. Explicit ARM targets get no + # target-cpu override (toolchain baseline); x86 targets use + # x86-64-v3. + if [[ "$TARGET" == "native" ]]; then + case "$(uname -m)" in + arm64|aarch64) export RUSTFLAGS="-C target-cpu=native" ;; + *) export RUSTFLAGS="-C target-cpu=x86-64-v3" ;; + esac + elif [[ "$TARGET" == "linux-arm" || "$TARGET" == "macos-arm" ]]; then + : # cross-building: no target-cpu override, use toolchain baseline + else + export RUSTFLAGS="-C target-cpu=x86-64-v3" + fi + echo "Building: max (LTO=fat, strip, target-cpu optimized)" + ;; + esac + + # Build command + if [[ -n "$RUST_TARGET" ]]; then + echo "Target: $RUST_TARGET" + cargo +{{stable_toolchain}} build --locked $CARGO_ARGS --target "$RUST_TARGET" + echo "Output: target/$RUST_TARGET/$([ "$LEVEL" = "debug" ] && echo "debug" || echo "release")/postmaster" + else + echo "Target: native ($(rustc +{{stable_toolchain}} -vV | grep host | cut -d' ' -f2))" + cargo +{{stable_toolchain}} build --locked $CARGO_ARGS + echo "Output: target/$([ "$LEVEL" = "debug" ] && echo "debug" || echo "release")/postmaster" + fi + +# Build all platforms at a given optimization level (requires cross-compilation setup) +build-all level="release": + #!/usr/bin/env bash + set -euo pipefail + echo "Building all platforms at '{{level}}' optimization level..." + echo "Note: cross targets need their toolchains installed first" + echo " (e.g. 'rustup target add '); a missing toolchain fails loudly." + for target in native linux linux-musl linux-arm macos macos-arm; do + echo "" + echo "=== Building target: $target ===" + just build-matrix {{level}} "$target" + done + +# Show build matrix help +build-help: + @echo "Build Matrix — Cross-platform builds with optimization levels" + @echo "" + @echo "USAGE:" + @echo " just build-matrix [target]" + @echo "" + @echo "ARGUMENTS:" + @echo " level Required. Optimization level (see below)" + @echo " target Optional. Platform target (default: native)" + @echo "" + @echo "OPTIMIZATION LEVELS:" + @echo " debug Fast compile, no optimizations, includes debug symbols" + @echo " Best for: development, debugging, quick iteration" + @echo "" + @echo " release Cargo defaults (opt-level=3, no LTO)" + @echo " Best for: testing release behavior, CI builds" + @echo "" + @echo " optimized LTO (thin) + single codegen unit" + @echo " Best for: staging deployments, performance testing" + @echo "" + @echo " max LTO (fat) + strip + CPU targeting (x86-64-v3 or native ARM)" + @echo " Best for: production deployments, final releases" + @echo "" + @echo "TARGETS:" + @echo " native Current platform (default)" + @echo " linux x86_64-unknown-linux-gnu" + @echo " linux-musl x86_64-unknown-linux-musl (static binary)" + @echo " linux-arm aarch64-unknown-linux-gnu (ARM64: Graviton, Pi 4/5)" + @echo " macos x86_64-apple-darwin (Intel Mac)" + @echo " macos-arm aarch64-apple-darwin (Apple Silicon: M1-M5)" + @echo "" + @echo "EXAMPLES:" + @echo " just build-matrix debug # Quick dev build" + @echo " just build-matrix release # Standard release" + @echo " just build-matrix optimized linux # Optimized for Linux" + @echo " just build-matrix max macos-arm # Max for Apple Silicon" + @echo " just build-matrix max linux-arm # Max for ARM Linux" + @echo "" + @echo "TRADE-OFFS (times/sizes vary with codebase growth):" + @echo " Level Compile Time Binary Size Runtime Perf" + @echo " --------- ------------- ----------- ------------" + @echo " debug fastest largest 1x (baseline)" + @echo " release ~1.3x debug ~3-4x smaller 3-5x faster" + @echo " optimized ~2x debug ~4-5x smaller 5-7x faster" + @echo " max ~3x debug smallest 7-10x faster" diff --git a/README.md b/README.md index 647ad3d..c77981a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ environment. Runtime flow per service (systemd path): -``` +```text 1. Nix store holds the encrypted .env file (ciphertext, public) ↓ 2. systemd LoadCredential= places key material in $CREDENTIALS_DIRECTORY @@ -101,7 +101,7 @@ fetches from remote systems: - **Files** — a `.env.keys`-format file on local disk (not in the Nix store) - **macOS Keychain** — key material stored as generic-password items, - read natively by postmaster via `security(1)` + read natively by postmaster via the Security.framework API - **Environment** — `DOTENV_PRIVATE_KEY*` variables in the process environment (set by the service manager) @@ -132,12 +132,17 @@ See [docs/src/SUMMARY.md](docs/src/SUMMARY.md) for the table of contents. ## Building ```console -$ cargo build -$ cargo test -$ cargo clippy --all-targets -$ cargo fmt --check +$ just build # debug build +$ just test # run tests +$ just clippy # lint +$ just fmt-check # format check +$ just ci-rust # all of the above in one command +$ just build-matrix max macos-arm # optimized release ``` +See `just build-help` for the full build matrix (optimization levels +and cross-compilation targets). + With Nix: ```console diff --git a/deny.toml b/deny.toml index 4672a7f..769800f 100644 --- a/deny.toml +++ b/deny.toml @@ -39,3 +39,23 @@ confidence-threshold = 0.93 # The postmaster crate is the only member today. [licenses.private] ignore = true + +[sources] +# The ecies fork in [patch.crates-io] (audit 2026-08-02 A7) is the only +# sanctioned git source; drop this entry together with the patch section +# once the upstream PR ships in a crates.io release. +allow-git = ["https://github.com/beardedeagle/rs.git"] + +[bans] +# Warn on duplicate crate versions — companion to clippy's +# multiple_crate_versions (crate-level; allowed at the crate root for +# exactly the case skipped below, since that lint has no per-package +# scoping). New duplicates should fail review, not land quietly. +multiple-versions = "warn" + +# syn 2 + syn 3 coexist ONLY because wasm-bindgen (target-gated to +# wasm32-unknown-unknown inside the ecies dep, never compiled for +# postmaster's real targets) pins syn 2 while serde_derive uses syn 3 — +# uncontrollable until upstream wasm-bindgen moves. Remove this skip, and +# the crate-root allow in src/lib.rs, when the tree converges. +skip = [{ name = "syn" }] diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 747c265..f0c9368 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -14,6 +14,15 @@ - [Materialization ABI](./architecture/materialization-abi.md) - [Threat model](./architecture/threat-model.md) +# Unsafe Code + +- [Overview](./unsafe-code/index.md) +- [Signal deferral](./unsafe-code/signal-deferral.md) +- [Sibling launch monitor](./unsafe-code/monitor.md) +- [macOS libc boundary](./unsafe-code/macos-libc-boundary.md) +- [systemd fd ownership (Linux)](./unsafe-code/systemd-fd-ownership.md) +- [Test-only env mutation](./unsafe-code/test-env-mutation.md) + # Reference - [CLI reference](./reference/cli.md) diff --git a/docs/src/architecture/materialization-abi.md b/docs/src/architecture/materialization-abi.md index 6b74ddd..32b38db 100644 --- a/docs/src/architecture/materialization-abi.md +++ b/docs/src/architecture/materialization-abi.md @@ -120,7 +120,8 @@ filename selects the private key variable: - `.env` maps to `DOTENV_PRIVATE_KEY` - `.env.production` maps to `DOTENV_PRIVATE_KEY_PRODUCTION` -- non-alphanumeric environment suffix bytes normalize to `_` +- environment names must be non-empty `[A-Za-z0-9_]` (letters fold to + upper case); anything else is rejected The `.env` adapter is the first adapter, not the ceiling of the system. @@ -139,7 +140,7 @@ diff, while encrypted values remain opaque base64 strings. "format": "postmaster.bundle", "version": 1, "profile": "production", - "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm", + "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm-entry-aad", "recipients": [ { "id": "host:api-01", @@ -160,10 +161,16 @@ diff, while encrypted values remain opaque base64 strings. ``` All fields above are public metadata except `ciphertext`, which is encrypted -and authenticated. The metadata that affects interpretation must be covered -by authentication so a store or repo adversary cannot rename a key, change a -profile, change a recipient id, switch an encoding, or replay a value between -profiles without detection. +and authenticated. Each entry's ciphertext is additionally bound to its key +name as AES-GCM additional authenticated data (the `-entry-aad` suite): a +store or repo adversary who cuts a ciphertext from one entry and pastes it +under another key name gets an authentication failure at decryption, not a +mislabeled secret. + +The remaining metadata (profile, recipient ids, encodings) is not yet +cryptographically bound. Binding it requires specifying canonical serialized +bytes for the authenticated metadata first — generated artifacts need +deterministic bytes for reproducible builds and stable test vectors. The design constraints are: @@ -179,9 +186,6 @@ The design constraints are: - `encoding` is either `utf8` or `bytes`. `env` projection rejects bytes that cannot become a POSIX environment value; `files` and `credentials` preserve byte-exact values. -- The canonical serialized bytes for authenticated metadata must be specified - before implementation. Generated artifacts need deterministic bytes for - reproducible builds and stable test vectors. - A bundle can contain many entries for one projection, but it does not encode service-manager policy. Projection remains in the `postmaster` config, not inside the encrypted artifact. @@ -199,7 +203,7 @@ The implementation ships with conformance vectors for: - unknown version - tampered authenticated metadata - wrong local private key -- replayed ciphertext under a different entry name or profile +- replayed ciphertext under a different entry name ## Key sources diff --git a/docs/src/architecture/postmaster.md b/docs/src/architecture/postmaster.md index df27dbe..53afc28 100644 --- a/docs/src/architecture/postmaster.md +++ b/docs/src/architecture/postmaster.md @@ -180,7 +180,7 @@ How key material reaches postmaster at runtime. - **Files** — a `.env.keys`-format file on local disk (not in the Nix store) - **macOS Keychain** — key material stored as generic-password items, - read natively by postmaster via `security(1)` + read natively by postmaster via the Security.framework API - **Environment** — `DOTENV_PRIVATE_KEY*` variables in the process environment (set by the service manager) @@ -200,13 +200,22 @@ context. This is the only stage postmaster owns. ### Stage 5: Launch (execvp) -Postmaster calls `execvp`. The process image is replaced. The child -binary IS `MainPID`. Postmaster is gone. +Postmaster forks a **sibling monitor** and then calls `execvp` in the +parent. The parent's process image is replaced. The child binary IS +`MainPID`. Postmaster is gone from the service's process. + +The fork-child is the monitor: secretless by construction (forked after +zeroization), it keeps the termination signals blocked for life and +supervises its parent — forwards stop signals with a 250 ms grace +before SIGKILL escalation, and removes the plaintext secret files on +every service-death path (natural exit, `kill -9`, crash), then exits. +One tiny extra process per service for the service's lifetime; liveness +is pidfd+`poll` on Linux and kqueue `NOTE_EXIT` on macOS. The service manager (systemd/launchd) supervises the child from this point. `ExecStopPost` runs `postmaster cleanup` to remove plaintext -files after the child exits (Linux). The ramdisk is destroyed when -the launchd job stops (macOS). +files after the child exits (Linux) — now a backstop behind the +monitor. The ramdisk is destroyed when the launchd job stops (macOS). ## Implementation @@ -243,17 +252,25 @@ and per-mode injection rules are the cross-binding contract at ## Dependency policy -Postmaster has 8 dependencies: +Postmaster has 10 direct dependencies (8 portable, 2 platform-gated): - `ecies` — crypto (secp256k1 + AES-256-GCM) - `dotenvy` — `.env` file parser - `serde` + `serde_json` — JSON config deserialization - `base64` — ciphertext decoding - `hex` — key parsing -- `rustix` — syscalls (no libc FFI on Linux) -- `libc` — macOS only (for `getpeereid` and `PT_DENY_ATTACH`) +- `rustix` — syscalls (raw syscalls via `linux_raw` on Linux, incl. the + monitor's pidfd/poll and the experimental `runtime` signal-mask API; + the only libc FFI on Linux is `fork(2)` — see the unsafe registry) +- `libc` — macOS (getpeereid, `PT_DENY_ATTACH`, the monitor's + kqueue/signal ops) plus Linux `fork(2)` only (rustix has no fork + wrapper — review-gated doctrine exception) +- `security-framework` — macOS only (Security.framework keychain reads) - `zeroize` — secret memory wiping +(`ecies` is currently a git+rev pin of the author's fork via +`[patch.crates-io]`, pending upstream PR ecies/rs#159.) + No CLI parsing library (clap, etc.). No async runtime. No OpenSSL. No network client. No plugin system. The dependency set is intentionally minimal: smaller binary, smaller attack surface, diff --git a/docs/src/architecture/threat-model.md b/docs/src/architecture/threat-model.md index eee1e7f..6413840 100644 --- a/docs/src/architecture/threat-model.md +++ b/docs/src/architecture/threat-model.md @@ -181,15 +181,15 @@ read or written: ### External binary verification -Postmaster verifies the integrity of external binaries before -invoking them in the secret path: +Keychain access on macOS uses the Security.framework API in-process — +no external binary is invoked for it. The external binaries postmaster +does invoke in secret-adjacent paths (`/usr/bin/id`, `/sbin/mount`, +`/usr/bin/hdiutil`) are verified before use: -- **macOS:** `/usr/bin/security` is verified via `codesign -v` before - being invoked for keychain access. This ensures the binary has a - valid Apple code signature and has not been tampered with or - replaced. -- **Linux:** External binaries are verified as regular files owned - by root (uid 0) and not world-writable. +- **macOS:** `codesign -v` verifies a valid Apple code signature + (defense-in-depth; the binaries are also SIP-protected). +- **Linux:** verified as regular files owned by root (uid 0) and not + world-writable. ### Input validation @@ -217,11 +217,14 @@ the secret path: ### Key freshness verification Postmaster provides `--verify-keys` on `exec` to detect stale keys -from a previous rotation cycle. After loading and decrypting, if the -resolved values are empty but the env files contain `encrypted:` -values, the keys are stale and the exec aborts with a clear error. -This catches the `strict: false` case where stale keys would silently -skip undecryptable values and launch the child with no secrets. +from a previous rotation cycle. After loading and decrypting, if any +value was skipped because the key ring could not decrypt it, the exec +aborts with a clear error naming the key. This catches the +`strict: false` case where stale keys would otherwise silently skip +undecryptable values and launch the child with a partial or empty +secret set. Bundle files need no such check: bundle resolution is +all-or-nothing, so a stale bundle key already aborts the exec +regardless of `strict`. ## Residuals: what postmaster cannot fully prevent @@ -250,6 +253,31 @@ A process running as root can read any other process's memory via always game over. Postmaster's `PR_SET_DUMPABLE=0` and `PT_DENY_ATTACH` prevent non-privileged peers from attaching, but cannot stop root. +### Rotation timing oracle + +When multiple private keys are configured for one variable (the +comma-separated rotation convention), decryption tries each candidate in +order and stops at the first that works. A local observer measuring +launch or fetch latency can infer which candidate matched — i.e., the +rotation position of the current key. Accepted (audit 2026-08-02 A14): +the observer learns only the rotation index, not key material, and +already needs same-host access; equalizing the timing would require +attempting every candidate on every decryption for no meaningful gain. + +### Env-file content is trusted input (no variable-name denylist) + +Postmaster injects what the configured env files and bundles say, +including plaintext passthrough entries. There is no denylist of +environment variable names (audit 2026-08-02 A18): names like +`LD_PRELOAD` or `PATH` are honored, because real deployments +legitimately set variables of this family (`LD_LIBRARY_PATH` is common). +The protection boundary is artifact authorship: anyone who can write the +env file (or produce valid ciphertext for it) already controls what the +child process receives. Treat plaintext passthrough entries in +world-readable artifacts as code-level intent, and keep artifact write +access tight. If a denylist is ever wanted, it belongs in config +validation as an explicit opt-in policy, not a silent default. + ### Swap and core dumps Swap and core dumps are the two ways plaintext can leak from memory to @@ -358,6 +386,22 @@ to be intact at the moment of `execvp`, and `execvp` replaces the process image without running destructors. Fork+wait would keep secrets in a long-lived parent heap (strictly worse) and was rejected. +### Monitor inherited heap — Mitigated + +The sibling monitor is forked after zeroization, so the key ring and +the `Zeroizing` resolved values are already wiped in the copy it +inherits. One plaintext-bearing structure does cross the fork: the +`extra` env array (plaintext values in env mode and in files-mode +passthrough). The monitor scrubs its copy **first**, before the +supervision loop starts: each `OsString` is consumed into its existing +buffer (no copy) and wiped in place with `zeroize` — free() alone +would leave the bytes in the monitor's mapped pages for its whole life, +and the monitor never allocates again, so they would never even be +reused. The parent's copy dies with `execvp` (image replacement). What +the monitor keeps beyond the scrub is non-secret metadata only: the +pinned secrets-dir fd and entry names (already visible in the +directory listing). + ### File-backed memory (page cache) — Mitigated by design Postmaster does not `mmap` secret files. Secret files on Linux tmpfs @@ -416,12 +460,54 @@ privileged memory inspection within the same kernel. ### Plaintext file persistence window (files mode) — Mitigated -Secret files written to the tmpfs/ramdisk are removed by `postmaster -cleanup --config `, wired to `ExecStopPost` in the systemd -Nix module — it runs immediately after the child exits. On macOS, the -ramdisk is destroyed when the `postmaster-ramdisk` launchd job stops. -The `prune_stale` function also removes stale files from prior -launches before writing new ones. +Secret files written to the tmpfs/ramdisk are removed by the **sibling +launch monitor** (design 2026-08-05), by `postmaster cleanup --config +` wired to `ExecStopPost` in the systemd Nix module, and by +ramdisk destruction when the `postmaster-ramdisk` launchd job stops. The +`prune_stale` function also removes stale files from prior launches +before writing new ones. + +A `TerminationGuard` blocks SIGTERM/SIGINT/SIGHUP/SIGQUIT across the +write window on both platforms, so a signal landing mid-write cannot +strand partial plaintext (audit 2026-08-02 A11). At the end of the +window — after zeroization, before `execvp` — the launcher forks. The +parent restores its pre-guard mask and execs the service (MainPID +unchanged); the fork-child is the **sibling monitor**: it keeps the +four termination signals blocked for life and owns stop semantics. + +*Monitor model (2026-08-05):* the monitor consumes stop signals +synchronously (no handlers anywhere — the sigaction flag-handler and +second pre-exec gate were retired with this design), forwards them to +the service, and escalates to SIGKILL after a **250 ms grace** — a +single global constant, deliberately not configurable (per-service +erosion of a security policy is unacceptable; the per-unit knob is the +manager's own stop timeout). Cleanup runs on **every** death path: +forwarded stops, natural exit, `kill -9`, crashes. Liveness uses a +pidfd + `poll` on Linux and kqueue `NOTE_EXIT` on macOS — both fire at +exit (zombie- and PID-reuse-proof); `kill(pid, 0)` polling is fallback +only. On systemd the monitor receives its own copy of cgroup-wide stop +signals, so it never depends on a signal the launcher already consumed. +On launchd, signals go to the job's main process only, so the monitor +is primarily a cleanup-watcher there — the macOS stop-path exposure +(plus the bounded PID-reuse window of its `kill(2)` forwarding, Darwin +having no pidfd) remains bounded by the ramdisk teardown at job stop, +the strongest backstop in the system. A monitor that dies before the +service leaves the pre-existing backstops (`ExecStopPost`, teardown, +`prune_stale`) — the same floor as before the monitor existed. + +### Shared `secrets_dir` (multi-tenant misuse) — Documented contract + +`secrets_dir` is single-tenant per service: `prune_stale` and the +monitor's cleanup delete files based on one service's key set, so two +services sharing one directory lets one service's lifecycle delete the +other's live files. The impact is availability, not confidentiality — +no plaintext crosses services. The generated Nix modules comply by +construction (Linux: each unit mounts a namespace-private tmpfs at the +path; darwin: per-service subdirectory under the shared ramdisk), so +this binds hand-written configs only: one `secrets_dir` per service. +Runtime enforcement via a service-identity sentinel is a +considered-and-deferred hardening for the broker era, when multi-tenant +topologies become real by design. ### Ramdisk mount verification (macOS) — Mitigated @@ -433,13 +519,13 @@ as the ramdisk. ### fd closure before execvp — Mitigated -`OFlags::CLOEXEC` is set on all `rustix::fs::open` calls (5 sites) — -the kernel automatically closes these on execvp, preventing the child -from inheriting postmaster's open file descriptors. Third-party code -(e.g., dotenvy) may open fds without `CLOEXEC`; a `pre_exec` hook to -close those was considered but not implemented because it requires -unsafe code or a new cross-platform dependency. Postmaster's own fds -are fully covered. +`OFlags::CLOEXEC` is set on every file descriptor postmaster opens (all +`rustix::fs::open`/`openat` sites) — the kernel automatically closes +these on execvp, preventing the child from inheriting postmaster's open +file descriptors. Third-party parsers never open their own fds in +postmaster's paths: dotenvy is only invoked as `dotenvy::from_read_iter` +on fds postmaster itself opened (`O_NOFOLLOW | CLOEXEC`). Postmaster's +own fds are fully covered. ### Secrets directory path exposure — Mitigated diff --git a/docs/src/changelog.md b/docs/src/changelog.md deleted file mode 100644 index 71fe45a..0000000 --- a/docs/src/changelog.md +++ /dev/null @@ -1,48 +0,0 @@ -# Changelog - -All notable changes to this project are documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- Standalone postmaster binary — a format-neutral local secret - materialization engine that decrypts encrypted inputs at process - launch time, injects them into a child process, and `execvp`s the - target binary. -- `.env` input adapter — wire-compatible with dotenvx-encrypted `.env` - files (ECIES/secp256k1 + HKDF-SHA256 + AES-256-GCM). -- `postmaster_bundle` JSON container format as a second input adapter. -- `KeyNaming` enum (Env, Bundle) for compile-time exhaustive adapter - dispatch — zero vtable overhead, controlled adapter set. -- Three injection modes: `env` (environment variables), `files` - (one-file-per-secret on RAM-backed filesystem), `credentials` - (systemd credentials via postmaster daemon). -- Four key sources: systemd credentials (`LoadCredential=`), files - (`.env.keys` on disk), macOS keychain, environment variables. -- `postmaster exec --check` for config shape validation (CI). -- `postmaster setup --recheck` for runtime key source verification - (host). -- `postmaster setup` interactive command — idempotent, creates keys - directory, verifies permissions, optionally seeds keychain (macOS). -- `postmaster cleanup --config ` subcommand for plaintext - secret file removal. Wired to `ExecStopPost` in systemd. -- `--verify-keys` flag on `exec` for stale key detection after rotation. -- `OFlags::CLOEXEC` on all `rustix::fs::open` call sites. -- RAM backing verification via `hdiutil info` on macOS. -- Pre-execvp zeroization of `KeyRing` and `Resolved` values. -- Symlink rejection at every secret path: `O_NOFOLLOW`, `fstat`, - `symlink_metadata`, `fchmod` on socket fd. -- External binary verification: `codesign -v` on macOS, root-owned - check on Linux. -- Input validation: NUL byte rejection, path traversal rejection, - identifier validation, no shell expansion invariant. -- Threat model documentation covering memory residency, hardening - inheritance across execvp, and the 5-stage secret pipeline. -- `verify_key_freshness` uses `KeyNaming::Env.metadata_prefix()`. -- Two security audits conducted (provider handoff + destination - handoff), all findings fixed. -- Memory hardening audit conducted, all findings fixed. diff --git a/docs/src/getting-started/key-provisioning.md b/docs/src/getting-started/key-provisioning.md index 8e06cbb..85942a1 100644 --- a/docs/src/getting-started/key-provisioning.md +++ b/docs/src/getting-started/key-provisioning.md @@ -93,7 +93,8 @@ $ security add-generic-password -a DOTENV_PRIVATE_KEY_PRODUCTION \ ``` No file on disk — keys live as generic-password items in the System -keychain. Postmaster reads them natively via `security(1)`. +keychain. Postmaster reads them natively via the Security.framework +API (`security-framework` crate). ## Rotation @@ -126,5 +127,5 @@ Use `--verify-keys` to catch stale keys before they cause a silent failure: ```console -$ postmaster exec --config exec.json --verify-keys -- /bin/true +$ postmaster exec --config exec.json --verify-keys -- /usr/bin/true ``` diff --git a/docs/src/getting-started/quick-start.md b/docs/src/getting-started/quick-start.md index 6359621..bf73d7c 100644 --- a/docs/src/getting-started/quick-start.md +++ b/docs/src/getting-started/quick-start.md @@ -39,14 +39,21 @@ API_TOKEN="encrypted:BGAAAAAADWyy..." ## Step 2: Provision keys -Postmaster needs the private key to decrypt the values. The key lives in -a `.env.keys` file: +Postmaster needs the private key to decrypt the values. `dotenvx set` +above wrote it to a `.env.keys` file alongside your env file: ```console -$ dotenvx keys -f secrets/.env.production -DOTENV_PRIVATE_KEY_PRODUCTION="a1b2c3d4e5f6..." +$ cat secrets/.env.keys +DOTENV_PRIVATE_KEY_PRODUCTION=a1b2c3d4e5f6... ``` +> **dotenvx version note:** the CLI changes across releases. Older +> versions exposed the private key through a `dotenvx keys` subcommand +> (since removed); current versions write it to the `.env.keys` file +> next to your env file, and the value may be quoted or unquoted +> depending on version. Treat the `.env.keys` file as the durable +> source — copy the `DOTENV_PRIVATE_KEY_*` value from it. + Place the keys file on the host (Linux): ```console diff --git a/docs/src/reference/cli.md b/docs/src/reference/cli.md index 11d7596..19e39b3 100644 --- a/docs/src/reference/cli.md +++ b/docs/src/reference/cli.md @@ -2,7 +2,7 @@ All postmaster commands, flags, and exit codes. The CLI is hand-rolled with no external parsing library — every flag below is the complete -set, directly from the argument parser in `config.rs`. +set, directly from the argument parser in `cli/mod.rs`. ## Synopsis @@ -65,10 +65,10 @@ $ postmaster exec --config [--check] [--verify-keys] -- [a - On failure the error is logged and the process exits non-zero. - `--check` does not accept a command after `--`. - `--verify-keys` performs a preflight check: after loading and - decrypting, if the resolved values are empty but the env files - contain `encrypted:` values, the keys are stale and the exec - aborts. This catches the `strict: false` case where stale keys - would silently skip undecryptable values. + decrypting, if any value was skipped because the key ring could not + decrypt it, the keys are stale and the exec aborts naming the key. + This catches the `strict: false` case where stale keys would + otherwise silently skip undecryptable values (partial or empty set). Exit codes: @@ -114,6 +114,12 @@ $ postmaster cleanup --config | --- | --- | | `--config ` | Path to `exec.json` (reads `secrets_dir` from config) | +Safety guard: cleanup refuses to bulk-delete a **non-empty** directory +that lacks the `.postmaster-managed` sentinel file (dropped by postmaster +when it writes there) — a misconfigured `secrets_dir` can never wipe a +directory postmaster doesn't manage. Empty or missing directories are +fine. + Wired to `ExecStopPost` in systemd by the Nix module. On macOS, the ramdisk destruction handles cleanup automatically when the launchd job stops. diff --git a/docs/src/reference/launcher-contract.md b/docs/src/reference/launcher-contract.md index 0b710b8..cccc723 100644 --- a/docs/src/reference/launcher-contract.md +++ b/docs/src/reference/launcher-contract.md @@ -48,8 +48,8 @@ binding to emit `exec.json`; it is not privileged over any other binding. **A binding is correct if and only if the `exec.json` it emits is described by this document and passes the conformance suite.** Nothing here documents intent or a roadmap; every claim below is true -of the committed parser and launcher (`src/config.rs`, -`src/launcher.rs`, `src/keys.rs`) as of this writing. +of the committed parser and launcher (`src/exec_config.rs`, +`src/launcher.rs`, `src/keys/mod.rs`) as of this writing. ### Config to launch flow @@ -100,11 +100,13 @@ $ postmaster cleanup --config reading key material, checking the filesystem, fetching sockets, or launching the child command. It does not accept a command after `--`. - `--verify-keys` performs a preflight check: after loading and decrypting, - if the resolved values are empty but the env files contain `encrypted:` - values, the keys are stale (from a previous rotation cycle) and the exec - aborts with a clear error. This catches the `strict: false` case where - stale keys would silently skip undecryptable values and launch the child - with no secrets. + if any value was skipped because the key ring could not decrypt it + (stale keys from a previous rotation cycle), the exec aborts with a + clear error naming the key. This catches the `strict: false` case where + stale keys would otherwise silently skip undecryptable values and + launch the child with a partial or empty secret set. Bundle files need + no such check: bundle resolution is all-or-nothing, so a stale bundle + key already aborts the exec regardless of `strict`. - `setup` verifies platform prerequisites, creates keys directory with 0700, verifies/creates keys files with 0600, optionally seeds keychain items (macOS), and validates the environment. Idempotent. `--recheck` @@ -126,7 +128,7 @@ $ postmaster cleanup --config ## `ExecConfig` fields -`exec.json` deserializes into this struct (`src/config.rs`). +`exec.json` deserializes into this struct (`src/exec_config.rs`). Unknown root fields are rejected. ABI fields such as `version` or `adapter` must not be emitted until the parser and schema explicitly define them. A machine-readable JSON Schema for this current shape is kept at @@ -162,6 +164,16 @@ before a real launch: non-empty, it also requires `secrets_dir` and a socket mapping for every credential key. +**One `secrets_dir` per service.** postmaster treats `secrets_dir` as +single-tenant: `prune_stale` at launch and cleanup on every death path +delete files there based on one service's key set, so two services +sharing a directory lets one service's lifecycle delete the other's live +files (an availability failure — plaintext never crosses services). The +generated Nix modules comply by construction — each Linux unit mounts a +namespace-private tmpfs at the path, and the darwin module writes +per-service subdirectories under the ramdisk — so this rule binds +hand-written configs. + `--check` also validates env-file basenames, rejects private-key `file` sources under `/nix/store`, rejects path-like `credential` source names, and applies identifier validation to `passthrough` and @@ -212,7 +224,7 @@ decryption happens in that mode; see below. ## Precedence contract -This is the contract `resolve_all` (`src/keys.rs`) implements, +This is the contract `resolve_all` (`src/keys/mod.rs`) implements, verbatim: - **Within one file, the last assignment wins** — ordinary dotenv @@ -265,14 +277,15 @@ ASCII whitespace (spaces, tabs, newlines, carriage returns). If any is present, it is stripped before decoding, so a value that has been line-wrapped decodes identically to the same value on one line. This matches the wire-compatible behavior for line-wrapped encrypted values and -is exercised directly in `tests/exec_conformance.rs` +is exercised directly in `tests/exec_conformance/main.rs` (`wrapped_base64_ciphertext_decrypts`). `key_var_for` maps an env-file name to the private-key variable it needs: `.env` → `DOTENV_PRIVATE_KEY`; `.env.production` → -`DOTENV_PRIVATE_KEY_PRODUCTION`; non-alphanumeric characters in the -environment suffix become `_` and the whole suffix is upper-cased. A file -name that doesn't start with `.env` is a hard parse error. +`DOTENV_PRIVATE_KEY_PRODUCTION`. The environment suffix must be non-empty +and contain only `[A-Za-z0-9_]` after upper-casing — anything else is a +hard parse error (no lossy mapping: `.env..x` and `.env._x` must never +alias). A file name that doesn't start with `.env` is a hard parse error. ## Per-mode injected variables @@ -345,10 +358,12 @@ this mode — it dispatches on whether `sockets` is empty: keys file with `O_NOFOLLOW` and `fstat`s the fd to verify: no group/world access (`mode & 0o077 == 0`), and owned by the current euid. A world-readable or symlinked keys file fails closed. -- **External binary verification.** On macOS, `from_keychain` verifies - the code signature of `/usr/bin/security` via `codesign -v` before - invoking it. On Linux, external binaries are verified as regular files - owned by root (uid 0) and not world-writable. +- **External binary verification.** Keychain access uses the + Security.framework API in-process — no external binary is invoked for + it. The external binaries postmaster does invoke (`/usr/bin/id`, + `/sbin/mount`, `/usr/bin/hdiutil` on macOS) are verified first: + `codesign -v` on macOS; on Linux, verified as regular files owned by + root (uid 0) and not world-writable. - **`verify_ramdisk` (darwin ramdisk guard).** When set, before any plaintext is written the launcher runs two checks: it shells out to `/sbin/mount` and requires the configured mount point to appear as an @@ -415,7 +430,7 @@ with external secret-management tooling that materializes a ## Conformance -`tests/exec_conformance.rs` is the executable form of this document: +`tests/exec_conformance/main.rs` is the executable form of this document: metacharacter-laden values stay inert across all three modes, process-env-wins-without-overload and overload-reverses-it, rotation with a bogus first key, strict failure refuses to exec, `0600` pointer files diff --git a/docs/src/unsafe-code/index.md b/docs/src/unsafe-code/index.md new file mode 100644 index 0000000..4e4d019 --- /dev/null +++ b/docs/src/unsafe-code/index.md @@ -0,0 +1,46 @@ +# Unsafe Code + +Postmaster deliberately allows `unsafe` at the lint level (documented in +`[lints.rust]` in `Cargo.toml`) because it is a syscall-boundary daemon: +peer-credential checks, anti-debug hardening, signal-mask deferral, the +sibling launch monitor (fork + pidfd/kqueue liveness), and the systemd +socket-activation contract cannot be expressed in safe Rust. +This section documents every approved site. + +## Policy + +Every `unsafe` block requires: + +1. A SAFETY comment at the site covering every UB vector +2. Minimal scope — the smallest possible unsafe block +3. Safe wrappers first — rustix (raw syscalls, no libc FFI) on Linux; + libc only where the platform's stable ABI *is* libc (Darwin) +4. Documentation in this section (registry entry + page) +5. Tests exercising the mitigation where the platform allows + +## Registry + +| Location | Platform | Purpose | Page | +|----------|----------|---------|------| +| `launcher.rs` (TerminationGuard: block/restore_mask/Drop) | macOS, Linux | Defer termination signals across the plaintext-write window; the fork-child monitor inherits the mask for life | [signal-deferral](./signal-deferral.md) | +| `launcher.rs` (fork(2)) | macOS, Linux | Fork the sibling monitor before exec — the single libc FFI call on Linux (doctrine exception) | [monitor](./monitor.md) | +| `monitor.rs` (signal mask/wait ops, pidfd+poll / kqueue, `_exit`/`exit_group`, raw stderr write) | macOS, Linux | Sibling monitor: synchronous stop consumption, liveness watch, cleanup on every death path | [monitor](./monitor.md) | +| `server.rs` (`peer_euid`) | macOS | Peer credential check via `getpeereid(2)` | [macos-libc-boundary](./macos-libc-boundary.md) | +| `server.rs` (`harden_process`) | macOS, release only | `PT_DENY_ATTACH` anti-debug hardening | [macos-libc-boundary](./macos-libc-boundary.md) | +| `server.rs` (socket activation) | Linux | `UnixListener::from_raw_fd` on systemd-inherited sockets | [systemd-fd-ownership](./systemd-fd-ownership.md) | +| `keys/source.rs`, `keys/env.rs` (test modules) | all, test-only | `set_var`/`remove_var` (unsafe on edition 2024) | [test-env-mutation](./test-env-mutation.md) | + +Note what is NOT in the inventory: `mlockall`, `setrlimit(Core, 0)`, and +`set_dumpable(NotDumpable)` hardening all use **safe** rustix wrappers. + +## Adding Unsafe Code + +1. **Exhaust safe alternatives** — rustix first; is there a safe way at all? +2. **Justify in the platform note** — why this platform has no safe route +3. **Minimize scope** — smallest possible unsafe block +4. **Mitigate all UB vectors** — each one named in the SAFETY comment +5. **Add to this section** — registry entry + a page following this format +6. **Write tests** — verify the mitigations work (or document why untestable) +7. **Get review** — unsafe requires explicit approval + +The bar is intentionally high. Postmaster's design keeps plaintext custody to microseconds — decrypt at the last instant, zeroize immediately, never persist. A memory-safety bug is one of the few things that can silently break that invariant. diff --git a/docs/src/unsafe-code/macos-libc-boundary.md b/docs/src/unsafe-code/macos-libc-boundary.md new file mode 100644 index 0000000..34c046a --- /dev/null +++ b/docs/src/unsafe-code/macos-libc-boundary.md @@ -0,0 +1,68 @@ +# macOS libc Boundary + +On Darwin the stable kernel ABI *is* libc, so these calls have no +raw-syscall route (contrast with Linux, where rustix's `linux_raw` +backend avoids libc FFI except `fork(2)`). This page covers the daemon's +two sites; the launcher's signal-mask guard and the sibling monitor's +kqueue/signal ops share the same justification and live on their own +pages ([signal deferral](./signal-deferral.md), +[sibling launch monitor](./monitor.md)). + +## getpeereid — peer credential check + +### Location + +``` +src/server.rs — fn peer_euid (macOS cfg only) +``` + +### Purpose + +The credential socket must only answer the service user. On Linux the +daemon reads peer credentials with `SO_PEERCRED` (safe rustix); Darwin +has no `SO_PEERCRED`, so the equivalent is `getpeereid(2)` via libc. + +### UB Vectors and Mitigations + +| Vector | Description | Mitigation | +|--------|-------------|------------| +| Out-param types | `getpeereid` writes a `uid_t` and a `gid_t` | Out-params are exactly `libc::uid_t`/`libc::gid_t` | +| fd validity | The socket fd must stay valid for the call | Borrowed from `stream` for the call's duration only | + +### Remaining Risks + +`getpeereid` establishes *same-euid*, not *same-process*: any process +running as the service user passes. That is an accepted residual of the +UID-based peer model (see the threat model), not a defect of this +wrapper. + +## PT_DENY_ATTACH — anti-debug hardening + +### Location + +``` +src/server.rs — fn harden_process (macOS cfg, release builds only) +``` + +### Purpose + +The ptrace-from-peers half of Linux's `PR_SET_DUMPABLE`: deny debugger +attachment to the daemon. Compiled only under `not(debug_assertions)`; +`[profile.release]` pins `debug-assertions = false` so a profile override +cannot silently compile it out (audit 2026-08-02 A23). Best-effort by +policy, but a failure is security-relevant and is logged (A10). + +### UB Vectors and Mitigations + +| Vector | Description | Mitigation | +|--------|-------------|------------| +| Bad arguments | `ptrace` is variadic-ish FFI | `PT_DENY_ATTACH` takes no pointer arguments: null addr, zero data — the documented invocation | +| Debug-build denial | Attaching a debugger in dev must stay possible | `#[cfg(not(debug_assertions))]` gate | + +## Tests + +The peer check is exercised wherever the daemon integration tests run on +macOS. `PT_DENY_ATTACH` cannot be meaningfully unit-tested (success +means a subsequent attach fails); its release-only gating is pinned by +`debug-assertions = false` in `[profile.release]` and verified by +release-profile clippy in the standard battery. diff --git a/docs/src/unsafe-code/monitor.md b/docs/src/unsafe-code/monitor.md new file mode 100644 index 0000000..d4ba6a3 --- /dev/null +++ b/docs/src/unsafe-code/monitor.md @@ -0,0 +1,103 @@ +# Sibling Launch Monitor + +## Location + +``` +src/monitor.rs — the monitor loop, liveness watches, signal ops, exit +src/launcher.rs — the fork(2) site and the Linux TerminationGuard leg +Unsafe blocks: fork(2) (libc, both platforms); rustix `runtime` + kernel_sigprocmask/kernel_sigwait (Linux); libc pthread_sigmask/ + sigpending/sigwait/kqueue/kevent/_exit (macOS); + OwnedFd::from_raw_fd (macOS kqueue fd); BorrowedFd::borrow_raw (stderr) +``` + +## Purpose + +The sibling-monitor launch supervision design (2026-08-05) closes the +launch-handoff stop-semantics gap and adds plaintext cleanup on **every** +service-death path. After validation, plaintext writes, and zeroization — +and before `execvp` — the launcher `fork()`s. The parent restores its +pre-guard signal mask and execs the service (MainPID unchanged); the +fork-child becomes the monitor: it keeps the four termination signals +blocked for life, consumes them synchronously (sigpending/sigwait — no +handlers anywhere), forwards stops to the service, escalates to SIGKILL +after a 250 ms grace (a single global security policy, deliberately not +configurable), watches liveness (pidfd+poll on Linux, kqueue +`EVFILT_PROC`/`NOTE_EXIT` on macOS, `kill(pid, 0)` only as fallback), and +runs `cleanup_written` before exiting — on stop, on natural exit, on +`kill -9`, on crash. The sigaction flag-handler and second pre-exec gate +were retired with this design: the monitor is the single owner of stop +semantics, so no async-signal context exists anywhere. + +## Why Unsafe + +- **`fork(2)` has no rustix wrapper** (a deliberate rustix omission), so + it is a direct libc call on both platforms. On Linux this is the + **single libc FFI call on the exec path** — an explicit, review-gated + exception to the "rustix linux_raw / no libc FFI on Linux" doctrine. + Everything else on the Linux leg is rustix. +- **Linux signal masking and synchronous consumption** use rustix's + `runtime` module (`kernel_sigprocmask`, `kernel_sigwait`, + `kernel_sigpending`, `exit_group`) — rustix's experimental, doc-hidden + API for libc-like runtimes. It is the only rustix route to signal masks + (there is no `rustix::process::sigprocmask`), and taking it avoids libc + signal FFI on Linux. Cargo.lock pins the exact rustix; an update that + changes this unstable API fails the build loudly, never silently. The + documented hazard class (a libc in the process reserves some signals) + does not apply: the mask set holds only TERM/INT/HUP/QUIT/PIPE, none of + which any libc reserves (glibc reserves 32/33). +- **Darwin has no raw-syscall ABI** and no `sigtimedwait` at all, so the + macOS leg uses libc for `pthread_sigmask`, `sigpending`, `sigwait`, + `kqueue`/`kevent`, and `_exit` — the same libc-signal family already on + this boundary, plus kqueue. rustix's `runtime` module does not exist + off Linux (it is `linux_raw_sys`-typed), and rustix's stable API has no + signal-mask or sigwait wrappers on any platform. +- `OwnedFd::from_raw_fd` (kqueue fd) and `BorrowedFd::borrow_raw(2)` + (monitor diagnostics) are fd-ownership assertions of the same kind as + the systemd socket boundary. + +Liveness findings that shaped the code: rustix 1.x **does** cover +`pidfd_open`/`pidfd_send_signal` (Linux-only) and `poll`, so the pidfd +leg needs no fallback in practice; `kill(pid, 0)` polling is kept only +for kernels without `pidfd_open` (< 5.3) and for kqueue failure. An +`ESRCH` from `pidfd_open`, from `kevent` registration, or from +`kill(pid, 0)` is race-free "definitely gone" and short-circuits to +cleanup (on macOS, xnu drops exited procs from the pid table before they +are reaped, so NOTE_EXIT registration on an already-exited service fails +`ESRCH` — handled as death, not as a fallback trigger). + +## UB Vectors and Mitigations + +| Vector | Description | Mitigation | +|--------|-------------|------------| +| Fork in a threaded process | The child inherits locked malloc/stdio state | The exec path is single-threaded at fork (worker threads exist only in serve mode). The child performs no allocation, no stdio locks, no destructor runs — only the async-signal-safe syscall set documented at the top of `monitor.rs` — and exits via `_exit`/`exit_group` (no atexit, no stdio flush). Tests fork from the multithreaded harness under the same discipline | +| Invalid `sigset_t` | Uninitialized set passed to sigwait/sigpending | `KernelSigSet::empty()` (rustix) / `zeroed()` + `sigemptyset` (libc) | +| Blocking sigwait hang | `sigwait` blocks until a set member is pending | Called only after `sigpending` shows a member pending — single-threaded, so nothing else can dequeue it first | +| Mask clobber | Restoring a mask never captured | Linux guard stores `None` on block failure (logged); restore then no-ops | +| Mask leak to the service | Service inheriting blocked termination signals | The parent restores the saved mask before `execvp` (explicit call + `Drop` on early-Err paths); the fork-child's guard is never dropped (`monitor::run` diverges, `_exit` runs no destructors), so the monitor keeps the mask for life | +| Wrong-fd ownership | Closing an fd not owned | `from_raw_fd` wraps a fresh `kqueue()` result exactly once; `borrow_raw(2)` never closes and never outlives the call (an absent stderr just fails the write with EBADF) | +| PID reuse | Signals/liveness hitting a recycled PID | pidfd pins the process: `poll` fires at exit (zombie-proof) and `pidfd_send_signal` cannot hit a recycled PID. `ESRCH` anywhere is treated as race-free death. Residual: the macOS stop-forward path uses plain `kill(2)` (Darwin has no pidfd) — a bounded exposure, accepted in the threat model | +| Zombie blindness | `kill(pid, 0)` succeeds on zombies | pidfd/kqueue are the primary mechanisms precisely because they fire at exit, not at reap; polling is fallback-only and documented | +| Handler unsoundness | Non-reentrant work in signal context | Eliminated by design — there are no signal handlers; consumption is synchronous in the loop | + +## Remaining Risks + +| Risk | Likelihood | Impact | +|------|------------|--------| +| rustix `runtime` API changes in a future rustix | Low (lockfile-pinned; build fails loudly at update time) | Build breakage, not a runtime defect | +| Monitor dies before the service | Very low (tiny, secretless loop) | Cleanup falls to the pre-existing backstops: `ExecStopPost`, ramdisk teardown, next-launch `prune_stale` — the same floor as before the monitor | +| Job-control stop (SIGTSTP) of the monitor's process group | Interactive sessions only | The monitor suspends with the service; cleanup waits for continuation — backstops remain | +| Service unkillable in D-state after SIGKILL | Kernel pathology | The monitor's post-SIGKILL death wait is unbounded by design (abandoning cleanup is worse); the manager's own escalation path still applies | +| Polling fallback on a pre-5.3 kernel | Legacy kernels only | Zombie-blindness until the parent reaps (systemd reaps promptly); PID-reuse exposure as documented | + +## Tests + +`monitor::tests` fork the REAL monitor loop in a real fork-child against +real service processes (deterministic; grace/poll injectable; no env +mutation): natural `kill -9` death → cleanup + exit; TERM to the monitor +→ forwarded (service dies by TERM, proving no escalation) → cleanup + +exit; a TERM-ignoring forked service (`SIG_IGN` + `pause`) → SIGKILL only +after the grace → cleanup + exit; env-mode (no fd, empty names) → exit on +service death. The exec integration suites +(`tests/exec_check.rs`, `tests/exec_conformance/`) exercise the full +fork+exec path and assert the post-exit cleanup contract. diff --git a/docs/src/unsafe-code/signal-deferral.md b/docs/src/unsafe-code/signal-deferral.md new file mode 100644 index 0000000..fad81eb --- /dev/null +++ b/docs/src/unsafe-code/signal-deferral.md @@ -0,0 +1,82 @@ +# Signal Deferral (TerminationGuard) + +## Location + +``` +src/launcher.rs — struct TerminationGuard (macOS and Linux legs) +Blocks: block() (sigmask), restore_mask() (sigmask), Drop (restore) +``` + +## Purpose + +Audit 2026-08-02 A11: on macOS, plaintext secret files persist on the +ramdisk until explicitly removed, so a SIGTERM landing *during* the +plaintext-write window would leave material on disk with no cleanup. +`TerminationGuard` blocks SIGTERM/SIGINT/SIGHUP/SIGQUIT +(`monitor::STOP_SIGNALS`) from just after config validation until the +fork/exec at the end of the window — a deterministic write window: no +termination signal can kill the launcher mid-write. + +At the end of the window the launcher forks the sibling monitor (see +[Sibling Launch Monitor](./monitor.md)), which **inherits the blocked +mask and keeps it for life** — the fork-child's guard is never dropped +(the monitor entry point diverges and exits via `_exit`/`exit_group`, +running no destructors). The parent calls `restore_mask()` before +`execvp` so the service starts with a clean signal state. + +The guard previously also carried a sigaction flag-recording handler +(`disarm`/`recorded`) and a second pre-exec gate to close the +check/exec race. Both were **retired with the sibling-monitor design +(2026-08-05)**: the monitor owns stop semantics on every death path, so +no async handler is needed anywhere and the handler's forever-purity +constraint is gone with it. What remains — `block()` and +`restore_mask()` — is what this page covers. + +The guard became real on Linux with the same change: the fork+monitor +model runs there too, and the blocked mask is the monitor's inheritance. +The Linux leg uses rustix's raw `rt_sigprocmask` (`runtime` module — the +experimental API; see the monitor page for the doctrinal discussion), so +the Linux exec path adds no libc signal FFI. + +## Why Unsafe + +Raw signal-mask operations — `pthread_sigmask` on Darwin (whose stable +kernel ABI is libc), `rt_sigprocmask` via rustix's `runtime` module on +Linux — are invisible to Rust's type system: a wrong mask or an invalid +`sigset_t` is undefined behavior the compiler cannot reject. The +pre-fork guard shares its platform notes with the monitor page: the +rustix `runtime` API is experimental (lockfile-pinned), and the mask set +holds only standard signals no libc reserves. + +## UB Vectors and Mitigations + +| Vector | Description | Mitigation | +|--------|-------------|------------| +| Invalid `sigset_t` | Uninitialized set passed to the mask call | `zeroed()` (valid empty representation) then `sigemptyset` on Darwin; `KernelSigSet::empty()` on Linux | +| Dangling pointers | set/old must outlive each call | Locals live for the full block; no pointers escape | +| Thread-model mismatch | `sigprocmask` is unspecified in multithreaded processes | `pthread_sigmask` form on Darwin; on Linux the launcher is single-threaded at this point (worker threads exist only in serve mode) | +| libc-reserved signals | Masking a signal a libc reserves is UB (rustix `runtime` safety note) | The set holds only TERM/INT/HUP/QUIT — none reserved (glibc reserves 32/33) | +| Mask clobber | Restoring a mask never captured | The Linux leg stores `None` on block failure (logged); restore then no-ops | +| Mask leak to the service | Service inheriting blocked termination signals | Mask restored before `execvp` on every path (explicit + `Drop` on early-Err) — the monitor *wants* the mask and is the only inheritor | + +## Remaining Risks + +| Risk | Likelihood | Impact | +|------|------------|--------| +| Kernel/libc signal quirks on future OS versions | Very low | Missed deferral (fail-open to pre-A11 behavior) | +| Operator sends SIGKILL mid-write | n/a | Unblockable by design; ramdisk contents persist until next boot (macOS) — accepted residual; the monitor covers everything *after* the fork | + +For the design-level stop semantics the guard feeds into (monitor +forwarding, grace, escalation, cleanup on every death path), see the +threat model's +[plaintext persistence window](../architecture/threat-model.md#plaintext-file-persistence-window-files-mode--mitigated) +section and the [monitor page](./monitor.md). + +## Tests + +The guard's contract is exercised end-to-end by the monitor tests +(`monitor::tests` — every one of them blocks via +`TerminationGuard::block()` before forking the real monitor loop, exactly +mirroring production) and by the exec integration suites +(`tests/exec_check.rs`, `tests/exec_conformance/`). The retired +flag-handler test was removed with the handler. diff --git a/docs/src/unsafe-code/systemd-fd-ownership.md b/docs/src/unsafe-code/systemd-fd-ownership.md new file mode 100644 index 0000000..85dd000 --- /dev/null +++ b/docs/src/unsafe-code/systemd-fd-ownership.md @@ -0,0 +1,42 @@ +# systemd fd Ownership (Linux) + +## Location + +``` +src/server.rs — socket-activation path (Linux cfg only) +``` + +## Purpose + +On Linux, postmaster is socket-activated: the `postmaster-credd.socket` +unit owns the per-(service, key) sockets and the daemon inherits them as +fds `[3, 3+n)`. Serving requires a `UnixListener`, so each inherited fd +is adopted with `UnixListener::from_raw_fd`. + +## Why Unsafe + +`from_raw_fd` asserts *ownership*: if the fd were invalid, duplicated +elsewhere, or owned by someone else, the result is double-close / +use-after-close — undefined behavior the compiler cannot check. + +## UB Vectors and Mitigations + +| Vector | Description | Mitigation | +|--------|-------------|------------| +| Wrong fd | Arbitrary fd adopted by mistake | The `sd_listen_fds(3)` protocol is validated first: `LISTEN_PID` names this process, so fds `[3, 3+n)` are exactly the sockets systemd created for us | +| Double ownership | Some other component also owns the fd | The protocol transfers ownership to this process; nothing else holds these fds | +| fd leak to child | Inherited socket leaking across execvp | `FD_CLOEXEC` is set on the listener immediately after adoption | +| Not-a-socket | Wrong unit passed non-socket fds | `getsockname(2)` mapping to the filesystem path fails closed immediately | + +## Remaining Risks + +| Risk | Likelihood | Impact | +|------|------------|--------| +| Misconfigured unit files (wrong `FileDescriptorName`/count) | Low | Fails closed at startup (path mapping), never serves a wrong socket | + +## Tests + +Not exercised on macOS dev hosts (the macOS leg uses `--bind`, which owns +its socket paths directly). The adoption is one line inside a path that +fails closed on every validation error; the daemon's fetch-loop +integration tests cover the serving behavior that follows it. diff --git a/docs/src/unsafe-code/test-env-mutation.md b/docs/src/unsafe-code/test-env-mutation.md new file mode 100644 index 0000000..aec22b3 --- /dev/null +++ b/docs/src/unsafe-code/test-env-mutation.md @@ -0,0 +1,43 @@ +# Test-Only env Mutation + +## Location + +``` +src/test_util.rs — the shared ENV_LOCK itself +src/keys/source.rs (test module) — 4 sites +src/keys/env.rs (test module) — 1 test, 2 set/remove pairs +src/keys/provisioning/mod.rs (test module) — 1 test, 2 set/remove pairs + a full vars() scan +``` + +## Purpose + +Edition 2024 marks `std::env::set_var`/`remove_var` `unsafe`: mutating +the process environment is a data race in a multithreaded process, and +the Rust test harness runs tests in parallel threads of one process. +Several key-source tests must set or clear `DOTENV_PRIVATE_KEY*` / +`CREDENTIALS_DIRECTORY` to exercise source selection. + +## UB Vectors and Mitigations + +| Vector | Description | Mitigation | +|--------|-------------|------------| +| Concurrent env access | Two tests mutating/reading env at once | Every call site holds the single process-wide `ENV_LOCK` (`crate::test_util`) for the mutation's full scope | +| Lock-ordering | Multiple env-mutating tests in one binary | One shared mutex for the whole test binary; no nested acquisition | + +Each site carries a SAFETY comment naming the serialization discipline. + +## Why one process-wide lock + +The discipline was previously **per-module**: `keys/source.rs` and +`keys/provisioning/mod.rs` each had their own `ENV_LOCK` static, and +`keys/env.rs`'s `from_env_loads_and_fails_closed` mutated env holding no +lock at all. Module-local locks do not exclude each other, so two such +tests still raced — the loser's panic poisoned a mutex and failed +unrelated tests (~1 in 5 baseline runs, observed 2026-08-04). Since +2026-08-06 every env-mutating test in every module acquires the single +`crate::test_util::ENV_LOCK`; any new env-touching test must do the same. + +## Tests + +These sites exist *in* tests; the shared-lock discipline is their +correctness property, exercised by every suite run. diff --git a/nixpkgs-package.nix b/nixpkgs-package.nix index 3bec812..fd699c4 100644 --- a/nixpkgs-package.nix +++ b/nixpkgs-package.nix @@ -38,7 +38,10 @@ rustPlatform.buildRustPackage rec { cargoLock = { lockFile = src + "/postmaster/Cargo.lock"; - # No outputHashes needed — all dependencies come from crates.io. + # While ecies is git-pinned via [patch.crates-io] (audit 2026-08-02 A7): + # the git dep's fetch hash. Drop once the upstream ecies PR (ecies/rs#159) + # ships on crates.io and the patch section is removed. + outputHashes."ecies-0.2.11" = "sha256-FOKSWQNJ9dZbQZdLI49o1OzeFoS3isQKchylQT/FaFk="; }; meta = { diff --git a/package.nix b/package.nix index 99d3f51..2787fd9 100644 --- a/package.nix +++ b/package.nix @@ -25,6 +25,11 @@ rustPlatform.buildRustPackage { ]; }; cargoLock.lockFile = ./Cargo.lock; + # Cargo.lock currently pins ecies to a git rev (see [patch.crates-io] in + # Cargo.toml, audit 2026-08-02 A7), so importCargoLock needs the git dep's + # fetch hash. Drop this again when the upstream ecies PR (ecies/rs#159) + # ships on crates.io and the patch section is removed. + cargoLock.outputHashes."ecies-0.2.11" = "sha256-FOKSWQNJ9dZbQZdLI49o1OzeFoS3isQKchylQT/FaFk="; meta = { description = cargoToml.package.description; diff --git a/src/bundle.rs b/src/adapter/bundle.rs similarity index 70% rename from src/bundle.rs rename to src/adapter/bundle.rs index 57a2e60..9aa9017 100644 --- a/src/bundle.rs +++ b/src/adapter/bundle.rs @@ -10,10 +10,11 @@ //! - `entries`: map of key name → `{ encoding, ciphertext }` //! //! All fields are public metadata except `ciphertext`, which is -//! encrypted and authenticated. Entries use the same identifier rule -//! as `.env`-derived keys: `[A-Za-z_][A-Za-z0-9_]*`. - -#![allow(missing_docs, missing_debug_implementations)] +//! encrypted and authenticated. Each entry's ciphertext is additionally +//! bound to its key name as AES-GCM additional authenticated data (AAD): +//! a ciphertext cut from one entry and pasted under another name fails +//! decryption (audit 2026-08-02 A7). Entries use the same identifier +//! rule as `.env`-derived keys: `[A-Za-z_][A-Za-z0-9_]*`. use std::path::Path; @@ -71,10 +72,18 @@ pub struct BundleEntry { #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum Encoding { + /// UTF-8 string value (env projection rejects non-UTF-8). Utf8, + /// Raw byte value (files/credentials preserve byte-exact). Bytes, } +/// The only supported crypto suite: ECIES (secp256k1, HKDF-SHA256, +/// AES-256-GCM) with the entry name bound as AES-GCM additional +/// authenticated data. Pre-AAD v1 ciphertexts are rejected at decryption +/// (authentication failure), not just here. +const SUITE: &str = "ecies-secp256k1-hkdf-sha256-aes-256-gcm-entry-aad"; + /// Parse a bundle JSON file from raw bytes. pub fn parse_bundle(raw: &[u8], path: &Path) -> Result { let bundle: Bundle = @@ -93,9 +102,9 @@ pub fn parse_bundle(raw: &[u8], path: &Path) -> Result { bundle.version )); } - if bundle.suite != "ecies-secp256k1-hkdf-sha256-aes-256-gcm" { + if bundle.suite != SUITE { return Err(format!( - "{}: unsupported suite {:?} (expected ecies-secp256k1-hkdf-sha256-aes-256-gcm)", + "{}: unsupported suite {:?} (expected {SUITE})", path.display(), bundle.suite )); @@ -136,21 +145,35 @@ pub fn resolve_bundle( .decode(&entry.ciphertext) .map_err(|_| format!("{}: {name} is not valid base64", bundle_path.display()))?, ); + // AAD = the entry name: a ciphertext pasted under a different + // entry fails authentication here (audit 2026-08-02 A7). let mut decrypted: Option>> = None; for sk in candidates { - if let Ok(pt) = ecies::decrypt(&sk[..], &ciphertext) { + if let Ok(pt) = ecies::decrypt_with_aad(&sk[..], &ciphertext, name.as_bytes()) { decrypted = Some(Zeroizing::new(pt)); break; } } match decrypted { - Some(pt) => out.push((name.clone(), pt, entry.encoding)), + Some(pt) => { + // `utf8` is a promise the bundle makes to env-mode + // consumers: validate it at the trust boundary and fail + // closed — invalid bytes must never flow into an + // environment value. `bytes` entries stay byte-exact. + if entry.encoding == Encoding::Utf8 && std::str::from_utf8(&pt).is_err() { + return Err(format!( + "{}: {name} declares utf8 but is not valid UTF-8", + bundle_path.display() + )); + } + out.push((name.clone(), pt, entry.encoding)); + } None => { let _ = ciphertext; // zeroize on drop + // No candidate count in the error text (audit 2026-08-02 A15). return Err(format!( - "{}: no key under {var} decrypts {name} ({} candidate(s) tried)", - bundle_path.display(), - candidates.len() + "{}: no key under {var} decrypts {name}", + bundle_path.display() )); } } @@ -186,7 +209,7 @@ mod tests { format: "postmaster.bundle".into(), version: 1, profile: Some("test".into()), - suite: "ecies-secp256k1-hkdf-sha256-aes-256-gcm".into(), + suite: SUITE.into(), recipients: vec![Recipient { id: "test".into(), public_key: test_pk_hex(), @@ -195,9 +218,9 @@ mod tests { } } - fn enc(value: &str) -> String { + fn enc(name: &str, value: &str) -> String { let pk = hex::decode(test_pk_hex()).unwrap(); - let ct = ecies::encrypt(&pk, value.as_bytes()).unwrap(); + let ct = ecies::encrypt_with_aad(&pk, value.as_bytes(), name.as_bytes()).unwrap(); BASE64.encode(ct) } @@ -206,7 +229,7 @@ mod tests { let raw = serde_json::json!({ "format": "wrong", "version": 1, - "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm", + "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm-entry-aad", "recipients": [], "entries": {} }); @@ -223,7 +246,7 @@ mod tests { let raw = serde_json::json!({ "format": "postmaster.bundle", "version": 99, - "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm", + "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm-entry-aad", "recipients": [], "entries": {} }); @@ -257,7 +280,7 @@ mod tests { let raw = serde_json::json!({ "format": "postmaster.bundle", "version": 1, - "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm", + "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm-entry-aad", "recipients": [], "entries": { "BAD.KEY": { "encoding": "utf8", "ciphertext": "dGVzdA==" } @@ -276,7 +299,7 @@ mod tests { let raw = serde_json::json!({ "format": "postmaster.bundle", "version": 1, - "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm", + "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm-entry-aad", "recipients": [], "entries": {}, "unknown_field": "should fail" @@ -292,7 +315,7 @@ mod tests { #[test] fn resolve_bundle_decrypts_entries() { - let ct = enc("hello-bundle"); + let ct = enc("SECRET", "hello-bundle"); let bundle = make_bundle(vec![("SECRET", &ct, Encoding::Utf8)]); let ring = crate::keys::tests::make_test_ring(); let resolved = resolve_bundle(&bundle, &ring, Path::new("/test")).unwrap(); @@ -302,14 +325,27 @@ mod tests { assert_eq!(resolved[0].2, Encoding::Utf8); } + #[test] + fn resolve_bundle_rejects_relocated_ciphertext() { + // audit 2026-08-02 A7: a ciphertext encrypted under one entry + // name must not decrypt under another. + let ct = enc("REAL", "top-secret"); + let bundle = make_bundle(vec![("FAKE", &ct, Encoding::Utf8)]); + let ring = crate::keys::tests::make_test_ring(); + let err = resolve_bundle(&bundle, &ring, Path::new("/test")).unwrap_err(); + assert!(err.contains("no key under"), "{err}"); + } + #[test] fn resolve_bundle_fails_on_wrong_key() { let other_sk = "22".repeat(32); let other_sk_bytes = hex::decode(&other_sk).unwrap(); let other_sk = ecies::SecretKey::parse_slice(&other_sk_bytes).unwrap(); let other_pk = ecies::PublicKey::from_secret_key(&other_sk); - let ct = BASE64 - .encode(ecies::encrypt(&other_pk.serialize_compressed(), b"unreachable").unwrap()); + let ct = BASE64.encode( + ecies::encrypt_with_aad(&other_pk.serialize_compressed(), b"unreachable", b"SECRET") + .unwrap(), + ); let bundle = make_bundle(vec![("SECRET", &ct, Encoding::Utf8)]); let ring = crate::keys::tests::make_test_ring(); let err = resolve_bundle(&bundle, &ring, Path::new("/test")).unwrap_err(); @@ -320,7 +356,7 @@ mod tests { fn resolve_bundle_handles_bytes_encoding() { let binary_val = b"\x00\x01\x02\xff"; let pk = hex::decode(test_pk_hex()).unwrap(); - let ct = BASE64.encode(ecies::encrypt(&pk, binary_val).unwrap()); + let ct = BASE64.encode(ecies::encrypt_with_aad(&pk, binary_val, b"TLS_KEY").unwrap()); let bundle = make_bundle(vec![("TLS_KEY", &ct, Encoding::Bytes)]); let ring = crate::keys::tests::make_test_ring(); let resolved = resolve_bundle(&bundle, &ring, Path::new("/test")).unwrap(); @@ -328,10 +364,32 @@ mod tests { assert_eq!(resolved[0].2, Encoding::Bytes); } + #[test] + fn resolve_bundle_rejects_invalid_utf8_when_declared_utf8() { + // An entry declaring `encoding: "utf8"` whose plaintext is not + // valid UTF-8 must fail closed; the same plaintext under + // `encoding: "bytes"` stays byte-exact. + let invalid = b"\xff\xfe"; + let pk = hex::decode(test_pk_hex()).unwrap(); + let ct = BASE64.encode(ecies::encrypt_with_aad(&pk, invalid, b"GREETING").unwrap()); + let ring = crate::keys::tests::make_test_ring(); + + let bundle = make_bundle(vec![("GREETING", &ct, Encoding::Utf8)]); + let err = resolve_bundle(&bundle, &ring, Path::new("/test")).unwrap_err(); + assert!( + err.contains("GREETING declares utf8 but is not valid UTF-8"), + "{err}" + ); + + let bundle = make_bundle(vec![("GREETING", &ct, Encoding::Bytes)]); + let resolved = resolve_bundle(&bundle, &ring, Path::new("/test")).unwrap(); + assert_eq!(&resolved[0].1[..], &invalid[..]); + } + #[test] fn resolve_bundle_multiple_entries() { - let ct1 = enc("value1"); - let ct2 = enc("value2"); + let ct1 = enc("KEY_A", "value1"); + let ct2 = enc("KEY_B", "value2"); let bundle = make_bundle(vec![ ("KEY_A", &ct1, Encoding::Utf8), ("KEY_B", &ct2, Encoding::Utf8), diff --git a/src/adapter.rs b/src/adapter/mod.rs similarity index 66% rename from src/adapter.rs rename to src/adapter/mod.rs index 5b75ddc..ffbca24 100644 --- a/src/adapter.rs +++ b/src/adapter/mod.rs @@ -11,7 +11,7 @@ //! every adapter at compile time — adding a variant is a deliberate act //! that forces all code paths to acknowledge it. -#![allow(missing_docs, missing_debug_implementations)] +pub mod bundle; use std::path::Path; @@ -74,16 +74,17 @@ impl KeyNaming { let env = rest.strip_prefix('.').ok_or_else(|| { format!("{base}: env files must follow the `.env[.]` convention") })?; - let suffix: String = env - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_uppercase() - } else { - '_' - } - }) - .collect(); + // Reject anything outside [A-Za-z0-9_]: such characters + // would sanitize to '_', making distinct filenames collide + // on the same key variable (`.env..x` ≡ `.env._x`) + // (audit 2026-08-02 A20). Letters fold to upper case, the + // dotenvx convention. + if env.is_empty() || !env.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err(format!( + "{base}: environment name must be non-empty [A-Za-z0-9_] only" + )); + } + let suffix: String = env.chars().map(|c| c.to_ascii_uppercase()).collect(); Ok(format!("{}_{}", self.key_prefix(), suffix)) } KeyNaming::Bundle => Ok(self.key_prefix().into()), @@ -98,3 +99,46 @@ impl KeyNaming { } } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn key_var_for_env_mapping() { + assert_eq!( + KeyNaming::Env.key_var_for(Path::new("/x/.env")).unwrap(), + "DOTENV_PRIVATE_KEY" + ); + assert_eq!( + KeyNaming::Env + .key_var_for(Path::new("/x/.env.production")) + .unwrap(), + "DOTENV_PRIVATE_KEY_PRODUCTION" + ); + } + + #[test] + fn key_var_for_rejects_aliasing_names() { + // audit 2026-08-02 A20: `.env..x` used to alias `.env._x` onto the + // same key variable (DOTENV_PRIVATE_KEY__X); lossy names now fail. + assert!(KeyNaming::Env.key_var_for(Path::new("/x/.env..x")).is_err()); + assert!(KeyNaming::Env.key_var_for(Path::new("/x/.env.-x")).is_err()); + assert!(KeyNaming::Env.key_var_for(Path::new("/x/.env.")).is_err()); + assert_eq!( + KeyNaming::Env.key_var_for(Path::new("/x/.env._x")).unwrap(), + "DOTENV_PRIVATE_KEY__X" + ); + } + + #[test] + fn key_var_for_bundle_is_constant() { + assert_eq!( + KeyNaming::Bundle + .key_var_for(Path::new("/x/anything.json")) + .unwrap(), + "POSTMASTER_KEY" + ); + } +} diff --git a/src/cli/help.rs b/src/cli/help.rs new file mode 100644 index 0000000..b72d5d1 --- /dev/null +++ b/src/cli/help.rs @@ -0,0 +1,88 @@ +//! Help text constants for the postmaster CLI. + +/// Top-level help text shown by `postmaster --help` or `postmaster help`. +pub const HELP_TOP: &str = "postmaster — standalone secret materialization engine\n\n\ +USAGE:\n \ +postmaster --config [--bind] [--allow-nonroot]\n \ +postmaster fetch \n \ +postmaster exec --config [--check] [--verify-keys] -- [args…]\n \ +postmaster setup --config [--recheck] [--unattended] [--force] [--keys-dir ]\n \ +postmaster help [subcommand]\n \ +postmaster --version\n\n\ +SUBCOMMANDS:\n \ +exec Decrypt secrets and exec a child process with them injected\n \ +fetch Fetch a single secret from a postmaster daemon socket\n \ +setup Verify environment, create keys directory, seed keychain (idempotent)\n \ +help Show help for a subcommand or this message\n\n\ +OPTIONS:\n \ +--config Configuration file (required for serve, exec, setup)\n \ +--bind (serve) Bind listener sockets even if not socket-activated\n \ +--allow-nonroot (serve) Allow non-root connections (dangerous)\n \ +--check (exec) Validate config shape only, no decryption or exec\n \ +--verify-keys (exec) Preflight key freshness check before exec\n \ +--recheck (setup) Verification only, no creation or modification\n \ +--unattended (setup) Non-interactive mode for automation (fail closed)\n \ +--force (setup) Re-create or re-seed even if already correct\n \ +--keys-dir

(setup) Override default keys directory path\n \ +--version, -V Print version and exit\n \ +--help, -h Show this help message\n"; + +/// Help text for the `exec` subcommand. +pub const HELP_EXEC: &str = "postmaster exec — decrypt secrets and exec a child process\n\n\ +USAGE:\n \ +postmaster exec --config [--check] [--verify-keys] -- [args…]\n\n\ +Decrypts the configured env files and/or bundle files using the loaded key\n\ +ring, injects the resolved values into the child environment (or writes them\n\ +as files / credential pointers depending on mode), and execs the command.\n\n\ +OPTIONS:\n \ +--config Exec config JSON (required)\n \ +--check Validate config shape only, no decryption, no exec\n \ +--verify-keys Preflight: fail unless EVERY value in every env file decrypts;\n \ +under `strict:false` any skipped/undecryptable value also aborts\n \ +the launch (fail-closed)\n \ +-- Everything after `--` is the child argv (required unless --check)\n\n\ +The config must specify mode (env/files/credentials), key sources, and\n\ +artifact paths. See docs/src/reference/launcher-contract.md for the full contract.\n"; + +/// Help text for the `fetch` subcommand. +pub const HELP_FETCH: &str = "postmaster fetch — fetch a secret from a daemon socket\n\n\ +USAGE:\n \ +postmaster fetch \n\n\ +Connects to a running postmaster daemon's AF_UNIX socket, requests the\n\ +default secret, and writes the decrypted bytes to stdout. Used internally\n\ +by the credentials-mode launcher on macOS (launchd) to pull secrets from\n\ +the daemon into the RAM disk.\n\n\ +The socket path must be a single relative or absolute path to the AF_UNIX\n\ +socket created by `postmaster --config `.\n"; + +/// Help text for the `setup` subcommand. +pub const HELP_SETUP: &str = "postmaster setup — verify environment and create key material\n\n\ +USAGE:\n \ +postmaster setup --config [--recheck] [--unattended] [--force] [--keys-dir ]\n\n\ +Verifies platform prerequisites, creates the keys directory with 0700\n\ +permissions, verifies or creates keys files with 0600, optionally seeds\n\ +keychain items (macOS, interactive), and validates the environment.\n\n\ +Idempotent — each step checks whether the target state is already present\n\ +and correct, and skips if it is. Safe to run on every converge cycle.\n\n\ +OPTIONS:\n \ +--config Exec config JSON (required)\n \ +--recheck Verification only — no creation or modification\n \ +--unattended Non-interactive mode for automation (fail closed, no prompts)\n \ +--force Re-create or re-seed even if already correct\n \ +--keys-dir Override the default keys directory\n\n\ +Default keys directory: /var/lib/postmaster (Linux),\n\ +~/Library/Application Support/postmaster (macOS).\n"; + +/// Help text for the `cleanup` subcommand. +pub const HELP_CLEANUP: &str = "postmaster cleanup — remove secret files from secrets_dir\n\n\ +USAGE:\n \ +postmaster cleanup --config \n\n\ +Reads the exec config, extracts secrets_dir, and removes all regular files\n\ +in that directory. Intended for ExecStopPost in systemd or equivalent\n\ +lifecycle hooks in other service managers.\n\n\ +Does not decrypt — only removes files that postmaster would have written.\n\ +Safety guard: a NON-EMPTY directory without a .postmaster-managed sentinel\n\ +file is left untouched (postmaster only bulk-deletes directories it manages).\n\ +Safe to run even if the directory is already empty or does not exist.\n\n\ +OPTIONS:\n \ +--config Exec config JSON (required)\n"; diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..4b811e0 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,529 @@ +//! Configuration parsing and data structures for postmaster. +//! +//! The config is generated by the Nix module as JSON. It contains only +//! public information (store paths for ciphertext + key *names*). + +use std::collections::HashMap; +use std::path::PathBuf; + +use serde::Deserialize; + +mod help; + +/// Help text constants, re-exported so `crate::config::HELP_*` keeps +/// resolving via the `cli as config` re-export in `lib.rs`. +pub use help::{HELP_CLEANUP, HELP_EXEC, HELP_FETCH, HELP_SETUP, HELP_TOP}; + +/// Full configuration loaded from the JSON file produced by the Nix module. +/// Unknown fields are rejected (audit 2026-08-02 A21) — same strictness +/// as the exec config. +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct Config { + /// Where the .env.keys material comes from. + pub keys: KeysSource, + /// Absolute socket path → what to serve on it. + pub credentials: HashMap, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +/// Where postmaster should obtain its own private keys. +pub enum KeysSource { + /// Name under $CREDENTIALS_DIRECTORY (the daemon's own LoadCredential). + Credential(String), + /// Absolute filesystem path (must not live in the Nix store). + File(PathBuf), +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(deny_unknown_fields)] +/// A single credential mapping served by postmaster. Unknown fields are +/// rejected (audit 2026-08-02 A21) — same strictness as `Config`. +pub struct Entry { + /// encrypted env file (a Nix store path; ciphertext is public). + pub env_file: PathBuf, + /// Which key inside it to serve. + pub key: String, + /// Additionally allow this (non-root) user to fetch the credential. + /// Used by the darwin module, where consumers are launchd daemons + /// connecting as their own service user rather than pid 1. + #[serde(default)] + pub peer_user: Option, +} + +/// Parsed command-line arguments for the `serve` invocation. +#[derive(Debug)] +pub struct Args { + /// Path to the daemon config JSON file. + pub config: PathBuf, + /// Bind listener sockets even when not socket-activated. + pub bind: bool, + /// Allow non-root peers to connect (dangerous, darwin only). + pub allow_nonroot: bool, +} + +/// The parsed top-level invocation selected from CLI arguments. +#[derive(Debug)] +pub enum Invocation { + /// `postmaster --config [--bind] [--allow-nonroot]`: + /// run the credential daemon (socket-activated on Linux, bind on macOS). + Serve(Args), + /// `postmaster fetch `: connect to a running daemon + /// socket and write the decrypted bytes to stdout. + Fetch(PathBuf), + /// `postmaster exec --config [--check] [--verify-keys]` + /// `-- `: decrypt + inject per the launcher contract, then + /// execvp argv. See launcher.rs. + Exec { + /// Path to the exec config JSON. + config: PathBuf, + /// Validate config shape only — no decryption, no exec. + check: bool, + /// Preflight: fail unless EVERY value in every env file + /// decrypts. Catches stale keys from a previous rotation cycle + /// that are valid hex but no longer match the ciphertext (audit + /// finding #4); under `strict: false` any skipped/undecryptable + /// value also aborts the launch (fail-closed). + verify_keys: bool, + /// Child argv — everything after `--` on the command line. + argv: Vec, + }, + /// `postmaster setup --config [--recheck] [--unattended] + /// [--force] [--keys-dir ]`: verify platform prerequisites, + /// create keys directory with correct permissions, optionally seed + /// keychain items, and validate the environment. Idempotent — skips + /// steps that are already correct. `--recheck` re-verifies without + /// creating or modifying anything. + Setup { + /// Path to the exec config JSON. + config: PathBuf, + /// Verification only — no creation or modification. + recheck: bool, + /// Non-interactive mode for automation (fail closed, no prompts). + unattended: bool, + /// Re-create or re-seed even if already correct. + force: bool, + /// Override the default keys directory path. + keys_dir: Option, + }, + /// `postmaster cleanup --config `: remove all secret files + /// from the secrets_dir configured in the exec config. Intended for + /// `ExecStopPost` in systemd or equivalent lifecycle hooks in other + /// service managers. Does not decrypt — only removes files that + /// postmaster would have written. Bulk removal is refused for a + /// non-empty directory lacking the `.postmaster-managed` sentinel + /// (audit 2026-08-02 A19). + Cleanup { + /// Path to the exec config JSON. + config: PathBuf, + }, + /// Print help text and exit successfully. + Help(Option), + /// Print version and exit successfully. + Version, +} + +/// Crate version, resolved at compile time from `CARGO_PKG_VERSION`. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Short usage string for error messages. +pub const USAGE: &str = "usage: postmaster --config [--bind] [--allow-nonroot]\n postmaster fetch \n postmaster exec --config [--check] [--verify-keys]\n postmaster exec --config [--verify-keys] -- [args…]\n postmaster setup --config [--recheck] [--unattended] [--force] [--keys-dir ]\n postmaster cleanup --config "; + +/// Parse command-line arguments from `std::env::args()`. +pub fn parse_args() -> Result { + parse_args_from(std::env::args().skip(1)) +} + +/// Parse command-line arguments from a provided iterator. Kept simple +/// with no external dependencies (no clap). +pub fn parse_args_from(args: impl IntoIterator) -> Result { + let mut it = args.into_iter().peekable(); + + // No args: print help and fail + if it.peek().is_none() { + return Err(format!( + "{HELP_TOP}\n\nerror: no subcommand or --config provided" + )); + } + + match it.peek().map(String::as_str) { + // Global flags that work before any subcommand + Some("--help") | Some("-h") => { + it.next(); + if it.next().is_some() { + return Err("error: --help does not take arguments".into()); + } + Ok(Invocation::Help(None)) + } + Some("--version") | Some("-V") => { + it.next(); + if it.next().is_some() { + return Err("error: --version does not take arguments".into()); + } + Ok(Invocation::Version) + } + Some("help") => { + it.next(); + let sub = it.next(); + if it.next().is_some() { + return Err( + "error: `help` takes at most one argument (the subcommand name)".into(), + ); + } + Ok(Invocation::Help(sub)) + } + Some("fetch") => { + it.next(); + let path = it.next().ok_or("error: fetch requires a socket path")?; + if it.next().is_some() { + return Err("error: fetch takes exactly one argument (the socket path)".into()); + } + Ok(Invocation::Fetch(PathBuf::from(path))) + } + Some("exec") => { + it.next(); + let mut config = None; + let mut check = false; + let mut verify_keys = false; + let mut argv: Vec = Vec::new(); + while let Some(a) = it.next() { + match a.as_str() { + "--config" => { + config = Some(PathBuf::from( + it.next().ok_or("error: --config requires a path")?, + )); + } + "--check" => check = true, + "--verify-keys" => verify_keys = true, + "--help" | "-h" => return Ok(Invocation::Help(Some("exec".into()))), + "--" => { + argv = it.collect(); + break; + } + other => { + return Err(format!( + "error: exec: unknown option `{other}`\n\n{HELP_EXEC}" + )); + } + } + } + if check { + if !argv.is_empty() { + return Err("error: exec --check does not accept a command after `--`".into()); + } + } else if argv.is_empty() { + return Err(format!( + "error: exec: missing command after `--`\n\n{HELP_EXEC}" + )); + } + Ok(Invocation::Exec { + config: config.ok_or("error: exec: missing required --config ")?, + check, + verify_keys, + argv, + }) + } + Some("setup") => { + it.next(); + let mut config = None; + let mut recheck = false; + let mut unattended = false; + let mut force = false; + let mut keys_dir = None; + while let Some(a) = it.next() { + match a.as_str() { + "--config" => { + config = Some(PathBuf::from( + it.next().ok_or("error: --config requires a path")?, + )); + } + "--recheck" => recheck = true, + "--unattended" | "--non-interactive" => unattended = true, + "--force" => force = true, + "--keys-dir" => { + keys_dir = Some(PathBuf::from( + it.next().ok_or("error: --keys-dir requires a path")?, + )); + } + "--help" | "-h" => return Ok(Invocation::Help(Some("setup".into()))), + other => { + return Err(format!( + "error: setup: unknown option `{other}`\n\n{HELP_SETUP}" + )); + } + } + } + let config = config.ok_or("error: setup: missing required --config ")?; + Ok(Invocation::Setup { + config, + recheck, + unattended, + force, + keys_dir, + }) + } + Some("cleanup") => { + it.next(); + let mut config = None; + while let Some(a) = it.next() { + match a.as_str() { + "--config" => { + config = Some(PathBuf::from( + it.next().ok_or("error: --config requires a path")?, + )); + } + "--help" | "-h" => return Ok(Invocation::Help(Some("cleanup".into()))), + other => { + return Err(format!( + "error: cleanup: unknown option `{other}`\n\n{HELP_CLEANUP}" + )); + } + } + } + Ok(Invocation::Cleanup { + config: config.ok_or("error: cleanup: missing required --config ")?, + }) + } + // Serve mode (default, no subcommand keyword) + Some("--config") | Some("--bind") | Some("--allow-nonroot") => { + let mut config = None; + let mut bind = false; + let mut allow_nonroot = false; + while let Some(a) = it.next() { + match a.as_str() { + "--config" => { + config = Some(PathBuf::from( + it.next().ok_or("error: --config requires a path")?, + )); + } + "--bind" => bind = true, + "--allow-nonroot" => allow_nonroot = true, + "--help" | "-h" => return Ok(Invocation::Help(None)), + other => { + return Err(format!("error: unknown option `{other}`\n\n{HELP_TOP}")); + } + } + } + Ok(Invocation::Serve(Args { + config: config.ok_or("error: missing required --config ")?, + bind, + allow_nonroot, + })) + } + Some(other) => Err(format!( + "error: unknown subcommand `{other}`\n\nTry `postmaster help` for usage.\n" + )), + None => unreachable!("empty args handled above"), + } +} + +#[cfg(test)] +#[allow(unused_qualifications, clippy::panic, clippy::unwrap_used)] +/// Tests for CLI argument parsing. +mod tests { + use super::*; + + fn parse(v: &[&str]) -> Result { + parse_args_from(v.iter().map(ToString::to_string)) + } + + #[test] + fn parse_exec() { + match parse(&[ + "exec", "--config", "/e.json", "--", "/bin/app", "--flag", "x", + ]) { + Ok(Invocation::Exec { + config, + check, + verify_keys, + argv, + }) => { + assert_eq!(config, PathBuf::from("/e.json")); + assert!(!check); + assert!(!verify_keys); + assert_eq!(argv, vec!["/bin/app", "--flag", "x"]); + } + other => panic!("expected Exec, got {other:?}"), + } + } + + #[test] + fn parse_exec_check() { + match parse(&["exec", "--config", "/e.json", "--check"]) { + Ok(Invocation::Exec { + config, + check, + argv, + .. + }) => { + assert_eq!(config, PathBuf::from("/e.json")); + assert!(check); + assert!(argv.is_empty()); + } + other => panic!("expected Exec check, got {other:?}"), + } + } + + #[test] + fn parse_exec_verify_keys() { + match parse(&[ + "exec", + "--config", + "/e.json", + "--verify-keys", + "--", + "/bin/app", + ]) { + Ok(Invocation::Exec { + config, + check, + verify_keys, + argv, + }) => { + assert_eq!(config, PathBuf::from("/e.json")); + assert!(!check); + assert!(verify_keys); + assert_eq!(argv, vec!["/bin/app"]); + } + other => panic!("expected Exec with --verify-keys, got {other:?}"), + } + } + + #[test] + fn parse_exec_requires_command() { + assert!(parse(&["exec", "--config", "/e.json"]).is_err()); + assert!(parse(&["exec", "--config", "/e.json", "--"]).is_err()); + assert!(parse(&["exec", "--config", "/e.json", "--check", "--", "/bin/app"]).is_err()); + } + + #[test] + fn parse_fetch_and_serve_still_work() { + assert!(matches!( + parse(&["fetch", "/s.sock"]), + Ok(Invocation::Fetch(_)) + )); + assert!(matches!( + parse(&["--config", "/c.json", "--bind"]), + Ok(Invocation::Serve(Args { bind: true, .. })) + )); + } + + #[test] + fn parse_help_top_level() { + assert!(matches!(parse(&["--help"]), Ok(Invocation::Help(None)))); + assert!(matches!(parse(&["-h"]), Ok(Invocation::Help(None)))); + } + + #[test] + fn parse_help_subcommand() { + assert!(matches!(parse(&["help"]), Ok(Invocation::Help(None)))); + assert!(matches!( + parse(&["help", "exec"]), + Ok(Invocation::Help(Some(s))) if s == "exec" + )); + assert!(matches!( + parse(&["help", "setup"]), + Ok(Invocation::Help(Some(s))) if s == "setup" + )); + } + + #[test] + fn parse_version() { + assert!(matches!(parse(&["--version"]), Ok(Invocation::Version))); + assert!(matches!(parse(&["-V"]), Ok(Invocation::Version))); + } + + #[test] + fn parse_no_args_fails() { + assert!(parse(&[]).is_err()); + } + + #[test] + fn config_rejects_unknown_fields_in_nested_entry() { + // audit 2026-08-02 A21: the nested credential Entry is as strict + // as the top-level Config — an unknown field is a binding bug, + // not something to silently ignore. + let raw = serde_json::json!({ + "keys": {"file": "/run/postmaster/.env.keys"}, + "credentials": { + "/run/cred.sock": {"env_file": "/nix/store/x/.env", "key": "DB_URL", "bogus": 1} + } + }); + let err = serde_json::from_value::(raw).unwrap_err(); + assert!(err.to_string().contains("unknown field"), "{err}"); + + // A well-formed config still parses. + let raw = serde_json::json!({ + "keys": {"credential": "postmaster-keys"}, + "credentials": { + "/run/cred.sock": {"env_file": "/nix/store/x/.env", "key": "DB_URL", "peer_user": "_www"} + } + }); + serde_json::from_value::(raw).unwrap(); + } + + #[test] + fn parse_unknown_subcommand_fails() { + let err = parse(&["bogus"]).unwrap_err(); + assert!(err.contains("unknown subcommand"), "{err}"); + assert!(err.contains("Try `postmaster help`"), "{err}"); + } + + #[test] + fn parse_help_in_exec_subcommand() { + assert!(matches!( + parse(&["exec", "--help"]), + Ok(Invocation::Help(Some(s))) if s == "exec" + )); + } + + #[test] + fn parse_help_in_setup_subcommand() { + assert!(matches!( + parse(&["setup", "--help"]), + Ok(Invocation::Help(Some(s))) if s == "setup" + )); + } + + #[test] + fn parse_setup_flags() { + assert!(matches!( + parse(&["setup", "--config", "/e.json", "--recheck"]), + Ok(Invocation::Setup { recheck: true, .. }) + )); + assert!(matches!( + parse(&["setup", "--config", "/e.json", "--unattended"]), + Ok(Invocation::Setup { + unattended: true, + .. + }) + )); + assert!(matches!( + parse(&["setup", "--config", "/e.json", "--force"]), + Ok(Invocation::Setup { force: true, .. }) + )); + assert!(matches!( + parse(&["setup", "--config", "/e.json", "--keys-dir", "/custom"]), + Ok(Invocation::Setup { keys_dir: Some(p), .. }) if &*p == std::path::Path::new("/custom") + )); + } + + #[test] + fn parse_cleanup() { + assert!(matches!( + parse(&["cleanup", "--config", "/e.json"]), + Ok(Invocation::Cleanup { config }) if &*config == std::path::Path::new("/e.json") + )); + assert!(parse(&["cleanup"]).is_err()); + assert!(parse(&["cleanup", "--config"]).is_err()); + } + + #[test] + fn parse_help_in_cleanup_subcommand() { + assert!(matches!( + parse(&["cleanup", "--help"]), + Ok(Invocation::Help(Some(s))) if s == "cleanup" + )); + } +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 33245c9..0000000 --- a/src/config.rs +++ /dev/null @@ -1,928 +0,0 @@ -//! Configuration parsing and data structures for postmaster. -//! -//! The config is generated by the Nix module as JSON. It contains only -//! public information (store paths for ciphertext + key *names*). - -#![allow(missing_docs, missing_debug_implementations)] - -use std::collections::HashMap; -use std::path::{Component, Path, PathBuf}; - -use serde::Deserialize; - -use crate::adapter::KeyNaming; - -/// Full configuration loaded from the JSON file produced by the Nix module. -#[derive(Deserialize, Debug)] -pub struct Config { - /// Where the .env.keys material comes from. - pub keys: KeysSource, - /// Absolute socket path → what to serve on it. - pub credentials: HashMap, -} - -#[derive(Deserialize, Debug)] -#[serde(rename_all = "snake_case")] -/// Where postmaster should obtain its own private keys. -pub enum KeysSource { - /// Name under $CREDENTIALS_DIRECTORY (the daemon's own LoadCredential). - Credential(String), - /// Absolute filesystem path (must not live in the Nix store). - File(PathBuf), -} - -#[derive(Deserialize, Clone, Debug)] -/// A single credential mapping served by postmaster. -pub struct Entry { - /// encrypted env file (a Nix store path; ciphertext is public). - pub env_file: PathBuf, - /// Which key inside it to serve. - pub key: String, - /// Additionally allow this (non-root) user to fetch the credential. - /// Used by the darwin module, where consumers are launchd daemons - /// connecting as their own service user rather than pid 1. - #[serde(default)] - pub peer_user: Option, -} - -#[derive(Debug)] -pub struct Args { - pub config: PathBuf, - pub bind: bool, - pub allow_nonroot: bool, -} - -#[derive(Debug)] -pub enum Invocation { - Serve(Args), - Fetch(PathBuf), - /// `postmaster exec --config [--check] [--verify-keys]` - /// `-- `: decrypt + inject per the launcher contract, then - /// execvp argv. See launcher.rs. - Exec { - config: PathBuf, - check: bool, - /// Preflight: attempt a test decryption of one encrypted value - /// from each env file before proceeding to exec. Catches stale - /// keys from a previous rotation cycle that are valid hex but - /// no longer match the ciphertext (audit finding #4). - verify_keys: bool, - argv: Vec, - }, - /// `postmaster setup --config [--recheck] [--unattended] - /// [--force] [--keys-dir ]`: verify platform prerequisites, - /// create keys directory with correct permissions, optionally seed - /// keychain items, and validate the environment. Idempotent — skips - /// steps that are already correct. `--recheck` re-verifies without - /// creating or modifying anything. - Setup { - config: PathBuf, - recheck: bool, - unattended: bool, - force: bool, - keys_dir: Option, - }, - /// `postmaster cleanup --config `: remove all secret files - /// from the secrets_dir configured in the exec config. Intended for - /// `ExecStopPost` in systemd or equivalent lifecycle hooks in other - /// service managers. Does not decrypt — only removes files that - /// postmaster would have written. - Cleanup { - config: PathBuf, - }, - /// Print help text and exit successfully. - Help(Option), - /// Print version and exit successfully. - Version, -} - -pub const VERSION: &str = env!("CARGO_PKG_VERSION"); - -pub const HELP_TOP: &str = "postmaster — standalone secret materialization engine\n\n\ -USAGE:\n \ -postmaster --config [--bind] [--allow-nonroot]\n \ -postmaster fetch \n \ -postmaster exec --config [--check] [--verify-keys] -- [args…]\n \ -postmaster setup --config [--recheck] [--unattended] [--force] [--keys-dir ]\n \ -postmaster help [subcommand]\n \ -postmaster --version\n\n\ -SUBCOMMANDS:\n \ -exec Decrypt secrets and exec a child process with them injected\n \ -fetch Fetch a single secret from a postmaster daemon socket\n \ -setup Verify environment, create keys directory, seed keychain (idempotent)\n \ -help Show help for a subcommand or this message\n\n\ -OPTIONS:\n \ ---config Configuration file (required for serve, exec, setup)\n \ ---bind (serve) Bind listener sockets even if not socket-activated\n \ ---allow-nonroot (serve) Allow non-root connections (dangerous)\n \ ---check (exec) Validate config shape only, no decryption or exec\n \ ---verify-keys (exec) Preflight key freshness check before exec\n \ ---recheck (setup) Verification only, no creation or modification\n \ ---unattended (setup) Non-interactive mode for automation (fail closed)\n \ ---force (setup) Re-create or re-seed even if already correct\n \ ---keys-dir

(setup) Override default keys directory path\n \ ---version, -V Print version and exit\n \ ---help, -h Show this help message\n"; - -pub const HELP_EXEC: &str = "postmaster exec — decrypt secrets and exec a child process\n\n\ -USAGE:\n \ -postmaster exec --config [--check] [--verify-keys] -- [args…]\n\n\ -Decrypts the configured env files and/or bundle files using the loaded key\n\ -ring, injects the resolved values into the child environment (or writes them\n\ -as files / credential pointers depending on mode), and execs the command.\n\n\ -OPTIONS:\n \ ---config Exec config JSON (required)\n \ ---check Validate config shape only, no decryption, no exec\n \ ---verify-keys Preflight: test-decrypt one value per env file before exec\n \ --- Everything after `--` is the child argv (required unless --check)\n\n\ -The config must specify mode (env/files/credentials), key sources, and\n\ -artifact paths. See docs/src/launcher-contract.md for the full contract.\n"; - -pub const HELP_FETCH: &str = "postmaster fetch — fetch a secret from a daemon socket\n\n\ -USAGE:\n \ -postmaster fetch \n\n\ -Connects to a running postmaster daemon's AF_UNIX socket, requests the\n\ -default secret, and writes the decrypted bytes to stdout. Used internally\n\ -by the credentials-mode launcher on macOS (launchd) to pull secrets from\n\ -the daemon into the RAM disk.\n\n\ -The socket path must be a single relative or absolute path to the AF_UNIX\n\ -socket created by `postmaster --config `.\n"; - -pub const HELP_SETUP: &str = "postmaster setup — verify environment and create key material\n\n\ -USAGE:\n \ -postmaster setup --config [--recheck] [--unattended] [--force] [--keys-dir ]\n\n\ -Verifies platform prerequisites, creates the keys directory with 0700\n\ -permissions, verifies or creates keys files with 0600, optionally seeds\n\ -keychain items (macOS, interactive), and validates the environment.\n\n\ -Idempotent — each step checks whether the target state is already present\n\ -and correct, and skips if it is. Safe to run on every converge cycle.\n\n\ -OPTIONS:\n \ ---config Exec config JSON (required)\n \ ---recheck Verification only — no creation or modification\n \ ---unattended Non-interactive mode for automation (fail closed, no prompts)\n \ ---force Re-create or re-seed even if already correct\n \ ---keys-dir Override the default keys directory\n\n\ -Default keys directory: /var/lib/postmaster (Linux),\n\ -~/Library/Application Support/postmaster (macOS).\n"; - -pub const HELP_CLEANUP: &str = "postmaster cleanup — remove secret files from secrets_dir\n\n\ -USAGE:\n \ -postmaster cleanup --config \n\n\ -Reads the exec config, extracts secrets_dir, and removes all regular files\n\ -in that directory. Intended for ExecStopPost in systemd or equivalent\n\ -lifecycle hooks in other service managers.\n\n\ -Does not decrypt — only removes files that postmaster would have written.\n\ -Safe to run even if the directory is already empty or does not exist.\n\n\ -OPTIONS:\n \ ---config Exec config JSON (required)\n"; - -pub const USAGE: &str = "usage: postmaster --config [--bind] [--allow-nonroot]\n postmaster fetch \n postmaster exec --config [--check] [--verify-keys]\n postmaster exec --config [--verify-keys] -- [args…]\n postmaster setup --config [--recheck] [--unattended] [--force] [--keys-dir ]\n postmaster cleanup --config "; - -/// Parse command line arguments. Kept simple with no external deps. -pub fn parse_args() -> Result { - parse_args_from(std::env::args().skip(1)) -} - -pub fn parse_args_from(args: impl IntoIterator) -> Result { - let mut it = args.into_iter().peekable(); - - // No args: print help and fail - if it.peek().is_none() { - return Err(format!( - "{HELP_TOP}\n\nerror: no subcommand or --config provided" - )); - } - - match it.peek().map(String::as_str) { - // Global flags that work before any subcommand - Some("--help") | Some("-h") => { - it.next(); - if it.next().is_some() { - return Err("error: --help does not take arguments".into()); - } - Ok(Invocation::Help(None)) - } - Some("--version") | Some("-V") => { - it.next(); - if it.next().is_some() { - return Err("error: --version does not take arguments".into()); - } - Ok(Invocation::Version) - } - Some("help") => { - it.next(); - let sub = it.next(); - if it.next().is_some() { - return Err( - "error: `help` takes at most one argument (the subcommand name)".into(), - ); - } - Ok(Invocation::Help(sub)) - } - Some("fetch") => { - it.next(); - let path = it.next().ok_or("error: fetch requires a socket path")?; - if it.next().is_some() { - return Err("error: fetch takes exactly one argument (the socket path)".into()); - } - Ok(Invocation::Fetch(PathBuf::from(path))) - } - Some("exec") => { - it.next(); - let mut config = None; - let mut check = false; - let mut verify_keys = false; - let mut argv: Vec = Vec::new(); - while let Some(a) = it.next() { - match a.as_str() { - "--config" => { - config = Some(PathBuf::from( - it.next().ok_or("error: --config requires a path")?, - )); - } - "--check" => check = true, - "--verify-keys" => verify_keys = true, - "--help" | "-h" => return Ok(Invocation::Help(Some("exec".into()))), - "--" => { - argv = it.collect(); - break; - } - other => { - return Err(format!( - "error: exec: unknown option `{other}`\n\n{HELP_EXEC}" - )); - } - } - } - if check { - if !argv.is_empty() { - return Err("error: exec --check does not accept a command after `--`".into()); - } - } else if argv.is_empty() { - return Err(format!( - "error: exec: missing command after `--`\n\n{HELP_EXEC}" - )); - } - Ok(Invocation::Exec { - config: config.ok_or("error: exec: missing required --config ")?, - check, - verify_keys, - argv, - }) - } - Some("setup") => { - it.next(); - let mut config = None; - let mut recheck = false; - let mut unattended = false; - let mut force = false; - let mut keys_dir = None; - while let Some(a) = it.next() { - match a.as_str() { - "--config" => { - config = Some(PathBuf::from( - it.next().ok_or("error: --config requires a path")?, - )); - } - "--recheck" => recheck = true, - "--unattended" | "--non-interactive" => unattended = true, - "--force" => force = true, - "--keys-dir" => { - keys_dir = Some(PathBuf::from( - it.next().ok_or("error: --keys-dir requires a path")?, - )); - } - "--help" | "-h" => return Ok(Invocation::Help(Some("setup".into()))), - other => { - return Err(format!( - "error: setup: unknown option `{other}`\n\n{HELP_SETUP}" - )); - } - } - } - let config = config.ok_or("error: setup: missing required --config ")?; - Ok(Invocation::Setup { - config, - recheck, - unattended, - force, - keys_dir, - }) - } - Some("cleanup") => { - it.next(); - let mut config = None; - while let Some(a) = it.next() { - match a.as_str() { - "--config" => { - config = Some(PathBuf::from( - it.next().ok_or("error: --config requires a path")?, - )); - } - "--help" | "-h" => return Ok(Invocation::Help(Some("cleanup".into()))), - other => { - return Err(format!( - "error: cleanup: unknown option `{other}`\n\n{HELP_CLEANUP}" - )); - } - } - } - Ok(Invocation::Cleanup { - config: config.ok_or("error: cleanup: missing required --config ")?, - }) - } - // Serve mode (default, no subcommand keyword) - Some("--config") | Some("--bind") | Some("--allow-nonroot") => { - let mut config = None; - let mut bind = false; - let mut allow_nonroot = false; - while let Some(a) = it.next() { - match a.as_str() { - "--config" => { - config = Some(PathBuf::from( - it.next().ok_or("error: --config requires a path")?, - )); - } - "--bind" => bind = true, - "--allow-nonroot" => allow_nonroot = true, - "--help" | "-h" => return Ok(Invocation::Help(None)), - other => { - return Err(format!("error: unknown option `{other}`\n\n{HELP_TOP}")); - } - } - } - Ok(Invocation::Serve(Args { - config: config.ok_or("error: missing required --config ")?, - bind, - allow_nonroot, - })) - } - Some(other) => Err(format!( - "error: unknown subcommand `{other}`\n\nTry `postmaster help` for usage.\n" - )), - None => unreachable!("empty args handled above"), - } -} - -/// Launcher configuration for `postmaster exec` — THE cross-binding -/// contract. Generated by the Nix module (later: other bindings); contains -/// only public data: store paths of ciphertext, key *names*, socket paths. -#[derive(Deserialize, Debug)] -#[serde(deny_unknown_fields)] -pub struct ExecConfig { - pub mode: ExecMode, - /// Ordered key sources; see launcher::load_ring for selection rules. - #[serde(default)] - pub keys: Vec, - /// Encrypted env files, in precedence order (first wins unless overload). - #[serde(default)] - pub env_files: Vec, - /// Encrypted `postmaster_bundle` files, in precedence order. - /// Each file is a JSON container with `format: "postmaster.bundle"`. - /// Entries are merged with `env_files` entries; bundle keys use the - /// same identifier rule and the same injection modes. - #[serde(default)] - pub bundle_files: Vec, - /// Any undecryptable value aborts before exec (fail closed). - #[serde(default = "default_true")] - pub strict: bool, - /// File values override process env; later files override earlier. - #[serde(default)] - pub overload: bool, - /// files/credentials(darwin) mode: where plaintext files land. - #[serde(default)] - pub secrets_dir: Option, - /// files mode: keys ALSO exported as real env vars. - #[serde(default)] - pub passthrough: Vec, - /// credentials mode: keys exposed as $CREDENTIALS_DIRECTORY/. - #[serde(default)] - pub credential_keys: Vec, - /// credentials mode, darwin: key → postmaster socket to fetch from. - /// Non-empty selects the fetch path; empty means systemd already - /// materialized the credentials. - #[serde(default)] - pub sockets: HashMap, - /// darwin: refuse to write plaintext unless this mount point is the - /// HFS ramdisk and secrets_dir is a directory we own. - #[serde(default)] - pub verify_ramdisk: Option, -} - -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ExecMode { - Env, - Files, - Credentials, -} - -/// Where the launcher's private keys come from. Selection contract: -/// `credential` is skippable (absent $CREDENTIALS_DIRECTORY or file ⇒ try -/// the next source); `file`/`keychain`/`environment` are terminal — once -/// reached, their failure is the launcher's failure (fail closed). -#[derive(Deserialize, Debug, Clone)] -#[serde(rename_all = "snake_case")] -pub enum KeySourceSpec { - Credential(String), - File(PathBuf), - Keychain(String), - Environment, -} - -impl ExecConfig { - /// Validate the public shape of an exec config without touching local - /// secret material, decrypting values, probing sockets, or inspecting the - /// host filesystem. This is the parser-authoritative `--check` contract - /// automation bindings can run in CI before deployment. - pub fn validate_shape(&self) -> Result<(), String> { - validate_key_sources(&self.keys)?; - validate_ident_list("passthrough", &self.passthrough)?; - validate_mode_irrelevant_fields(self)?; - validate_mode_required_fields(self)?; - Ok(()) - } -} - -fn validate_mode_required_fields(cfg: &ExecConfig) -> Result<(), String> { - match cfg.mode { - ExecMode::Env => { - validate_decrypting_mode(cfg, "env")?; - } - ExecMode::Files => { - validate_decrypting_mode(cfg, "files")?; - if cfg.secrets_dir.is_none() { - return Err("files mode requires secrets_dir".into()); - } - } - ExecMode::Credentials => { - if cfg.credential_keys.is_empty() { - return Err("credentials mode requires credential_keys".into()); - } - validate_ident_list("credential_keys", &cfg.credential_keys)?; - if !cfg.sockets.is_empty() { - if cfg.secrets_dir.is_none() { - return Err("credentials(fetch) mode requires secrets_dir".into()); - } - for key in &cfg.credential_keys { - if !cfg.sockets.contains_key(key) { - return Err(format!("{key}: no socket mapped in exec config")); - } - } - } - } - } - Ok(()) -} - -/// Reject fields that are irrelevant to the selected mode. This catches -/// binding bugs early — before any secret material transits — so a config -/// generator that accidentally includes `env_files` in a credentials-mode -/// config or `credential_keys` in an env-mode config fails at `--check` -/// rather than silently ignoring the stray field. Default/empty values -/// (empty vecs, `None`, `false`) are accepted since serde fills them in -/// even when the generator omits the field entirely. -fn validate_mode_irrelevant_fields(cfg: &ExecConfig) -> Result<(), String> { - match cfg.mode { - ExecMode::Env => { - if cfg.secrets_dir.is_some() { - return Err("env mode does not use secrets_dir".into()); - } - if !cfg.credential_keys.is_empty() { - return Err("env mode does not use credential_keys".into()); - } - if !cfg.sockets.is_empty() { - return Err("env mode does not use sockets".into()); - } - if cfg.verify_ramdisk.is_some() { - return Err("env mode does not use verify_ramdisk".into()); - } - if !cfg.passthrough.is_empty() { - return Err("env mode does not use passthrough".into()); - } - } - ExecMode::Files => { - if !cfg.credential_keys.is_empty() { - return Err("files mode does not use credential_keys".into()); - } - if !cfg.sockets.is_empty() { - return Err("files mode does not use sockets".into()); - } - } - ExecMode::Credentials => { - if !cfg.keys.is_empty() { - return Err( - "credentials mode does not use keys (key material lives on the daemon)".into(), - ); - } - if !cfg.env_files.is_empty() { - return Err( - "credentials mode does not use env_files (ciphertext lives on the daemon)" - .into(), - ); - } - if !cfg.bundle_files.is_empty() { - return Err( - "credentials mode does not use bundle_files (ciphertext lives on the daemon)" - .into(), - ); - } - if cfg.overload { - return Err("credentials mode does not use overload".into()); - } - if !cfg.passthrough.is_empty() { - return Err("credentials mode does not use passthrough".into()); - } - } - } - Ok(()) -} - -fn validate_decrypting_mode(cfg: &ExecConfig, mode: &str) -> Result<(), String> { - if cfg.env_files.is_empty() && cfg.bundle_files.is_empty() { - return Err(format!( - "{mode} mode requires env_files or bundle_files (at least one non-empty)" - )); - } - if cfg.keys.is_empty() { - return Err(format!("{mode} mode requires keys")); - } - for path in &cfg.env_files { - KeyNaming::Env.key_var_for(path)?; - } - Ok(()) -} - -fn validate_key_sources(specs: &[KeySourceSpec]) -> Result<(), String> { - for spec in specs { - match spec { - KeySourceSpec::Credential(name) => validate_credential_source_name(name)?, - KeySourceSpec::File(path) if path.starts_with("/nix/store") => { - return Err(format!( - "{}: refusing plaintext private keys from the world-readable Nix store", - path.display() - )); - } - _ => {} - } - } - Ok(()) -} - -fn validate_credential_source_name(name: &str) -> Result<(), String> { - if name.contains('\0') { - return Err(format!( - "credential source {name:?}: contains NUL byte (not a valid path component)" - )); - } - let mut components = Path::new(name).components(); - match (components.next(), components.next()) { - (Some(Component::Normal(_)), None) => Ok(()), - _ => Err(format!( - "credential source {name:?}: must be a single relative path component" - )), - } -} - -fn validate_ident_list(field: &str, keys: &[String]) -> Result<(), String> { - for key in keys { - if !crate::keys::is_ident(key) { - return Err(format!("{field} entry {key:?}: not a valid identifier")); - } - } - Ok(()) -} - -fn default_true() -> bool { - true -} - -#[cfg(test)] -#[allow(unused_qualifications, clippy::panic, clippy::unwrap_used)] -mod tests { - use super::*; - - fn parse(v: &[&str]) -> Result { - parse_args_from(v.iter().map(ToString::to_string)) - } - - #[test] - fn parse_exec() { - match parse(&[ - "exec", "--config", "/e.json", "--", "/bin/app", "--flag", "x", - ]) { - Ok(Invocation::Exec { - config, - check, - verify_keys, - argv, - }) => { - assert_eq!(config, PathBuf::from("/e.json")); - assert!(!check); - assert!(!verify_keys); - assert_eq!(argv, vec!["/bin/app", "--flag", "x"]); - } - other => panic!("expected Exec, got {other:?}"), - } - } - - #[test] - fn parse_exec_check() { - match parse(&["exec", "--config", "/e.json", "--check"]) { - Ok(Invocation::Exec { - config, - check, - argv, - .. - }) => { - assert_eq!(config, PathBuf::from("/e.json")); - assert!(check); - assert!(argv.is_empty()); - } - other => panic!("expected Exec check, got {other:?}"), - } - } - - #[test] - fn parse_exec_verify_keys() { - match parse(&[ - "exec", - "--config", - "/e.json", - "--verify-keys", - "--", - "/bin/app", - ]) { - Ok(Invocation::Exec { - config, - check, - verify_keys, - argv, - }) => { - assert_eq!(config, PathBuf::from("/e.json")); - assert!(!check); - assert!(verify_keys); - assert_eq!(argv, vec!["/bin/app"]); - } - other => panic!("expected Exec with --verify-keys, got {other:?}"), - } - } - - #[test] - fn parse_exec_requires_command() { - assert!(parse(&["exec", "--config", "/e.json"]).is_err()); - assert!(parse(&["exec", "--config", "/e.json", "--"]).is_err()); - assert!(parse(&["exec", "--config", "/e.json", "--check", "--", "/bin/app"]).is_err()); - } - - #[test] - fn parse_fetch_and_serve_still_work() { - assert!(matches!( - parse(&["fetch", "/s.sock"]), - Ok(Invocation::Fetch(_)) - )); - assert!(matches!( - parse(&["--config", "/c.json", "--bind"]), - Ok(Invocation::Serve(Args { bind: true, .. })) - )); - } - - #[test] - fn parse_help_top_level() { - assert!(matches!(parse(&["--help"]), Ok(Invocation::Help(None)))); - assert!(matches!(parse(&["-h"]), Ok(Invocation::Help(None)))); - } - - #[test] - fn parse_help_subcommand() { - assert!(matches!(parse(&["help"]), Ok(Invocation::Help(None)))); - assert!(matches!( - parse(&["help", "exec"]), - Ok(Invocation::Help(Some(s))) if s == "exec" - )); - assert!(matches!( - parse(&["help", "setup"]), - Ok(Invocation::Help(Some(s))) if s == "setup" - )); - } - - #[test] - fn parse_version() { - assert!(matches!(parse(&["--version"]), Ok(Invocation::Version))); - assert!(matches!(parse(&["-V"]), Ok(Invocation::Version))); - } - - #[test] - fn parse_no_args_fails() { - assert!(parse(&[]).is_err()); - } - - #[test] - fn parse_unknown_subcommand_fails() { - let err = parse(&["bogus"]).unwrap_err(); - assert!(err.contains("unknown subcommand"), "{err}"); - assert!(err.contains("Try `postmaster help`"), "{err}"); - } - - #[test] - fn parse_help_in_exec_subcommand() { - assert!(matches!( - parse(&["exec", "--help"]), - Ok(Invocation::Help(Some(s))) if s == "exec" - )); - } - - #[test] - fn parse_help_in_setup_subcommand() { - assert!(matches!( - parse(&["setup", "--help"]), - Ok(Invocation::Help(Some(s))) if s == "setup" - )); - } - - #[test] - fn parse_setup_flags() { - assert!(matches!( - parse(&["setup", "--config", "/e.json", "--recheck"]), - Ok(Invocation::Setup { recheck: true, .. }) - )); - assert!(matches!( - parse(&["setup", "--config", "/e.json", "--unattended"]), - Ok(Invocation::Setup { - unattended: true, - .. - }) - )); - assert!(matches!( - parse(&["setup", "--config", "/e.json", "--force"]), - Ok(Invocation::Setup { force: true, .. }) - )); - assert!(matches!( - parse(&["setup", "--config", "/e.json", "--keys-dir", "/custom"]), - Ok(Invocation::Setup { keys_dir: Some(p), .. }) if &*p == std::path::Path::new("/custom") - )); - } - - #[test] - fn parse_cleanup() { - assert!(matches!( - parse(&["cleanup", "--config", "/e.json"]), - Ok(Invocation::Cleanup { config }) if &*config == std::path::Path::new("/e.json") - )); - assert!(parse(&["cleanup"]).is_err()); - assert!(parse(&["cleanup", "--config"]).is_err()); - } - - #[test] - fn parse_help_in_cleanup_subcommand() { - assert!(matches!( - parse(&["cleanup", "--help"]), - Ok(Invocation::Help(Some(s))) if s == "cleanup" - )); - } - - #[test] - fn key_var_for_edge_cases() { - assert!( - KeyNaming::Env - .key_var_for(std::path::Path::new("no-dot-env")) - .is_err() - ); - } - - #[test] - fn exec_config_parses() { - let json = r#"{ - "mode": "files", - "keys": [{"credential": "postmaster-myapp"}, "environment"], - "env_files": ["/nix/store/x/.env.production"], - "strict": true, - "overload": false, - "secrets_dir": "/run/postmaster", - "passthrough": ["RUST_LOG"] - }"#; - let c: ExecConfig = serde_json::from_str(json).unwrap(); - assert!(matches!(c.mode, ExecMode::Files)); - assert_eq!(c.keys.len(), 2); - assert!(matches!(c.keys[1], KeySourceSpec::Environment)); - assert!(c.strict && !c.overload); - assert!(c.sockets.is_empty() && c.verify_ramdisk.is_none()); - } - - #[test] - fn exec_config_minimal_credentials() { - let json = r#"{"mode":"credentials","credential_keys":["DB_URL"]}"#; - let c: ExecConfig = serde_json::from_str(json).unwrap(); - assert!(matches!(c.mode, ExecMode::Credentials)); - assert!(c.strict, "strict must default to true"); - assert_eq!(c.credential_keys, vec!["DB_URL"]); - c.validate_shape().unwrap(); - } - - #[test] - fn exec_config_rejects_unknown_fields() { - let json = r#"{"mode":"credentials","credential_keys":["DB_URL"],"adapter":"dotenvx"}"#; - let err = serde_json::from_str::(json).unwrap_err(); - assert!( - err.to_string().contains("unknown field"), - "unexpected error: {err}" - ); - } - - #[test] - fn exec_config_validate_shape_catches_mode_requirements() { - let files_missing_dir: ExecConfig = serde_json::from_str( - r#"{"mode":"files","keys":["environment"],"env_files":["/nix/store/x/.env"]}"#, - ) - .unwrap(); - assert!( - files_missing_dir - .validate_shape() - .unwrap_err() - .contains("secrets_dir") - ); - - let invalid_credential_key: ExecConfig = - serde_json::from_str(r#"{"mode":"credentials","credential_keys":["../DB_URL"]}"#) - .unwrap(); - assert!( - invalid_credential_key - .validate_shape() - .unwrap_err() - .contains("identifier") - ); - - let store_key: ExecConfig = serde_json::from_str( - r#"{"mode":"env","keys":[{"file":"/nix/store/abc/.env.keys"}],"env_files":["/nix/store/x/.env"]}"#, - ) - .unwrap(); - assert!( - store_key - .validate_shape() - .unwrap_err() - .contains("Nix store") - ); - } - - #[test] - fn validate_shape_rejects_mode_irrelevant_fields() { - // env mode should reject secrets_dir, credential_keys, sockets, - // verify_ramdisk, and passthrough. - let env_with_secrets_dir: ExecConfig = serde_json::from_str( - r#"{"mode":"env","keys":["environment"],"env_files":["/nix/store/x/.env"],"secrets_dir":"/run/x"}"#, - ) - .unwrap(); - assert!( - env_with_secrets_dir - .validate_shape() - .unwrap_err() - .contains("env mode does not use secrets_dir") - ); - - let env_with_credential_keys: ExecConfig = serde_json::from_str( - r#"{"mode":"env","keys":["environment"],"env_files":["/nix/store/x/.env"],"credential_keys":["DB"]}"#, - ) - .unwrap(); - assert!( - env_with_credential_keys - .validate_shape() - .unwrap_err() - .contains("env mode does not use credential_keys") - ); - - // files mode should reject credential_keys and sockets. - let files_with_sockets: ExecConfig = serde_json::from_str( - r#"{"mode":"files","keys":["environment"],"env_files":["/nix/store/x/.env"],"secrets_dir":"/run/x","sockets":{"DB":"/s.sock"}}"#, - ) - .unwrap(); - assert!( - files_with_sockets - .validate_shape() - .unwrap_err() - .contains("files mode does not use sockets") - ); - - // credentials mode should reject keys, env_files, overload, passthrough. - let creds_with_env_files: ExecConfig = serde_json::from_str( - r#"{"mode":"credentials","credential_keys":["DB"],"env_files":["/nix/store/x/.env"]}"#, - ) - .unwrap(); - assert!( - creds_with_env_files - .validate_shape() - .unwrap_err() - .contains("credentials mode does not use env_files") - ); - - let creds_with_overload: ExecConfig = serde_json::from_str( - r#"{"mode":"credentials","credential_keys":["DB"],"overload":true}"#, - ) - .unwrap(); - assert!( - creds_with_overload - .validate_shape() - .unwrap_err() - .contains("credentials mode does not use overload") - ); - } -} diff --git a/src/exec_config.rs b/src/exec_config.rs new file mode 100644 index 0000000..bd2ecc3 --- /dev/null +++ b/src/exec_config.rs @@ -0,0 +1,425 @@ +//! Exec configuration — the `exec.json` data model and validation. +//! +//! This module defines the cross-binding contract for `postmaster exec`: +//! the `ExecConfig` struct, `ExecMode` enum, `KeySourceSpec` enum, and +//! all validation functions that check config shape without touching +//! local secret material or the host filesystem. + +use std::collections::HashMap; +use std::path::{Component, Path, PathBuf}; + +use serde::Deserialize; + +use crate::adapter::KeyNaming; + +/// Launcher configuration for `postmaster exec` — THE cross-binding +/// contract. Generated by the Nix module (later: other bindings); contains +/// only public data: store paths of ciphertext, key *names*, socket paths. +#[derive(Deserialize, Debug)] +#[serde(deny_unknown_fields)] +pub struct ExecConfig { + /// How resolved values are projected into the child process. + pub mode: ExecMode, + /// Ordered key sources; see launcher::load_ring for selection rules. + #[serde(default)] + pub keys: Vec, + /// Encrypted env files, in precedence order (first wins unless overload). + #[serde(default)] + pub env_files: Vec, + /// Encrypted `postmaster_bundle` files, in precedence order. + /// Each file is a JSON container with `format: "postmaster.bundle"`. + /// Entries are merged with `env_files` entries; bundle keys use the + /// same identifier rule and the same injection modes. + #[serde(default)] + pub bundle_files: Vec, + /// Any undecryptable value aborts before exec (fail closed). + #[serde(default = "default_true")] + pub strict: bool, + /// File values override process env; later files override earlier. + #[serde(default)] + pub overload: bool, + /// files/credentials(darwin) mode: where plaintext files land. + #[serde(default)] + pub secrets_dir: Option, + /// files mode: keys ALSO exported as real env vars. + #[serde(default)] + pub passthrough: Vec, + /// credentials mode: keys exposed as $CREDENTIALS_DIRECTORY/. + #[serde(default)] + pub credential_keys: Vec, + /// credentials mode, darwin: key → postmaster socket to fetch from. + /// Non-empty selects the fetch path; empty means systemd already + /// materialized the credentials. + #[serde(default)] + pub sockets: HashMap, + /// darwin: refuse to write plaintext unless this mount point is the + /// HFS ramdisk and secrets_dir is a directory we own. + #[serde(default)] + pub verify_ramdisk: Option, +} + +/// How resolved secret values are projected into the child process. +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ExecMode { + /// Export all resolved values as environment variables. + Env, + /// Write each resolved value as a file under `secrets_dir`. + Files, + /// systemd credentials: export `_FILE` pointers into + /// `$CREDENTIALS_DIRECTORY`. + Credentials, +} + +/// Where the launcher's private keys come from. Selection contract: +/// `credential` is skippable (absent $CREDENTIALS_DIRECTORY or file ⇒ try +/// the next source); `file`/`keychain`/`environment` are terminal — once +/// reached, their failure is the launcher's failure (fail closed). +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub enum KeySourceSpec { + /// Read from `$CREDENTIALS_DIRECTORY/` (systemd LoadCredential). + Credential(String), + /// Read from a local file path (must not be in the Nix store). + File(PathBuf), + /// Read from macOS Keychain (terminal, macOS only). + Keychain(String), + /// Read `DOTENV_PRIVATE_KEY*` from the process environment. + Environment, +} + +impl ExecConfig { + /// Validate the public shape of an exec config without touching local + /// secret material, decrypting values, probing sockets, or inspecting the + /// host filesystem. This is the parser-authoritative `--check` contract + /// automation bindings can run in CI before deployment. + pub fn validate_shape(&self) -> Result<(), String> { + validate_key_sources(&self.keys)?; + validate_ident_list("passthrough", &self.passthrough)?; + validate_mode_irrelevant_fields(self)?; + validate_mode_required_fields(self)?; + Ok(()) + } + + /// The key-naming conventions this config's artifacts require: + /// `Env` when `env_files` are present, `Bundle` when `bundle_files` + /// are present (both when both are). `load_ring` admits exactly these + /// namings into the key ring — no more (least privilege on key + /// material), no less (a bundle config must actually see + /// `POSTMASTER_KEY`). + pub fn required_namings(&self) -> Vec { + let mut namings = Vec::with_capacity(2); + if !self.env_files.is_empty() { + namings.push(KeyNaming::Env); + } + if !self.bundle_files.is_empty() { + namings.push(KeyNaming::Bundle); + } + namings + } +} + +fn validate_mode_required_fields(cfg: &ExecConfig) -> Result<(), String> { + match cfg.mode { + ExecMode::Env => { + validate_decrypting_mode(cfg, "env")?; + } + ExecMode::Files => { + validate_decrypting_mode(cfg, "files")?; + if cfg.secrets_dir.is_none() { + return Err("files mode requires secrets_dir".into()); + } + } + ExecMode::Credentials => { + if cfg.credential_keys.is_empty() { + return Err("credentials mode requires credential_keys".into()); + } + validate_ident_list("credential_keys", &cfg.credential_keys)?; + if !cfg.sockets.is_empty() { + if cfg.secrets_dir.is_none() { + return Err("credentials(fetch) mode requires secrets_dir".into()); + } + for key in &cfg.credential_keys { + if !cfg.sockets.contains_key(key) { + return Err(format!("{key}: no socket mapped in exec config")); + } + } + } + } + } + Ok(()) +} + +/// Reject fields that are irrelevant to the selected mode. This catches +/// binding bugs early — before any secret material transits — so a config +/// generator that accidentally includes `env_files` in a credentials-mode +/// config or `credential_keys` in an env-mode config fails at `--check` +/// rather than silently ignoring the stray field. Default/empty values +/// (empty vecs, `None`, `false`) are accepted since serde fills them in +/// even when the generator omits the field entirely. +fn validate_mode_irrelevant_fields(cfg: &ExecConfig) -> Result<(), String> { + match cfg.mode { + ExecMode::Env => { + if cfg.secrets_dir.is_some() { + return Err("env mode does not use secrets_dir".into()); + } + if !cfg.credential_keys.is_empty() { + return Err("env mode does not use credential_keys".into()); + } + if !cfg.sockets.is_empty() { + return Err("env mode does not use sockets".into()); + } + if cfg.verify_ramdisk.is_some() { + return Err("env mode does not use verify_ramdisk".into()); + } + if !cfg.passthrough.is_empty() { + return Err("env mode does not use passthrough".into()); + } + } + ExecMode::Files => { + if !cfg.credential_keys.is_empty() { + return Err("files mode does not use credential_keys".into()); + } + if !cfg.sockets.is_empty() { + return Err("files mode does not use sockets".into()); + } + } + ExecMode::Credentials => { + if !cfg.keys.is_empty() { + return Err( + "credentials mode does not use keys (key material lives on the daemon)".into(), + ); + } + if !cfg.env_files.is_empty() { + return Err( + "credentials mode does not use env_files (ciphertext lives on the daemon)" + .into(), + ); + } + if !cfg.bundle_files.is_empty() { + return Err( + "credentials mode does not use bundle_files (ciphertext lives on the daemon)" + .into(), + ); + } + if cfg.overload { + return Err("credentials mode does not use overload".into()); + } + if !cfg.passthrough.is_empty() { + return Err("credentials mode does not use passthrough".into()); + } + } + } + Ok(()) +} + +fn validate_decrypting_mode(cfg: &ExecConfig, mode: &str) -> Result<(), String> { + if cfg.env_files.is_empty() && cfg.bundle_files.is_empty() { + return Err(format!( + "{mode} mode requires env_files or bundle_files (at least one non-empty)" + )); + } + if cfg.keys.is_empty() { + return Err(format!("{mode} mode requires keys")); + } + for path in &cfg.env_files { + KeyNaming::Env.key_var_for(path)?; + } + Ok(()) +} + +fn validate_key_sources(specs: &[KeySourceSpec]) -> Result<(), String> { + for spec in specs { + match spec { + KeySourceSpec::Credential(name) => validate_credential_source_name(name)?, + KeySourceSpec::File(path) if path.starts_with("/nix/store") => { + return Err(format!( + "{}: refusing plaintext private keys from the world-readable Nix store", + path.display() + )); + } + _ => {} + } + } + Ok(()) +} + +pub(crate) fn validate_credential_source_name(name: &str) -> Result<(), String> { + if name.contains('\0') { + return Err(format!( + "credential source {name:?}: contains NUL byte (not a valid path component)" + )); + } + let mut components = Path::new(name).components(); + match (components.next(), components.next()) { + (Some(Component::Normal(_)), None) => Ok(()), + _ => Err(format!( + "credential source {name:?}: must be a single relative path component" + )), + } +} + +fn validate_ident_list(field: &str, keys: &[String]) -> Result<(), String> { + for key in keys { + if !crate::keys::is_ident(key) { + return Err(format!("{field} entry {key:?}: not a valid identifier")); + } + } + Ok(()) +} + +fn default_true() -> bool { + true +} + +#[cfg(test)] +#[allow(unused_qualifications, clippy::panic, clippy::unwrap_used)] +/// Tests for exec config parsing and validation. +mod tests { + use super::*; + + #[test] + fn key_var_for_edge_cases() { + assert!( + KeyNaming::Env + .key_var_for(std::path::Path::new("no-dot-env")) + .is_err() + ); + } + + #[test] + fn exec_config_parses() { + let json = r#"{ + "mode": "files", + "keys": [{"credential": "postmaster-myapp"}, "environment"], + "env_files": ["/nix/store/x/.env.production"], + "strict": true, + "overload": false, + "secrets_dir": "/run/postmaster", + "passthrough": ["RUST_LOG"] + }"#; + let c: ExecConfig = serde_json::from_str(json).unwrap(); + assert!(matches!(c.mode, ExecMode::Files)); + assert_eq!(c.keys.len(), 2); + assert!(matches!(c.keys[1], KeySourceSpec::Environment)); + assert!(c.strict && !c.overload); + assert!(c.sockets.is_empty() && c.verify_ramdisk.is_none()); + } + + #[test] + fn exec_config_minimal_credentials() { + let json = r#"{"mode":"credentials","credential_keys":["DB_URL"]}"#; + let c: ExecConfig = serde_json::from_str(json).unwrap(); + assert!(matches!(c.mode, ExecMode::Credentials)); + assert!(c.strict, "strict must default to true"); + + c.validate_shape().unwrap(); + } + + #[test] + fn exec_config_rejects_unknown_fields() { + let json = r#"{"mode":"credentials","credential_keys":["DB_URL"],"adapter":"dotenvx"}"#; + let err = serde_json::from_str::(json).unwrap_err(); + assert!( + err.to_string().contains("unknown field"), + "unexpected error: {err}" + ); + } + + #[test] + fn exec_config_validate_shape_catches_mode_requirements() { + let files_missing_dir: ExecConfig = serde_json::from_str( + r#"{"mode":"files","keys":["environment"],"env_files":["/nix/store/x/.env"]}"#, + ) + .unwrap(); + assert!( + files_missing_dir + .validate_shape() + .unwrap_err() + .contains("secrets_dir") + ); + + let invalid_credential_key: ExecConfig = + serde_json::from_str(r#"{"mode":"credentials","credential_keys":["../DB_URL"]}"#) + .unwrap(); + assert!( + invalid_credential_key + .validate_shape() + .unwrap_err() + .contains("identifier") + ); + + let store_key: ExecConfig = serde_json::from_str( + r#"{"mode":"env","keys":[{"file":"/nix/store/abc/.env.keys"}],"env_files":["/nix/store/x/.env"]}"#, + ) + .unwrap(); + assert!( + store_key + .validate_shape() + .unwrap_err() + .contains("Nix store") + ); + } + + #[test] + fn validate_shape_rejects_mode_irrelevant_fields() { + // env mode should reject secrets_dir, credential_keys, sockets, + // verify_ramdisk, and passthrough. + let env_with_secrets_dir: ExecConfig = serde_json::from_str( + r#"{"mode":"env","keys":["environment"],"env_files":["/nix/store/x/.env"],"secrets_dir":"/run/x"}"#, + ) + .unwrap(); + assert!( + env_with_secrets_dir + .validate_shape() + .unwrap_err() + .contains("env mode does not use secrets_dir") + ); + + let env_with_credential_keys: ExecConfig = serde_json::from_str( + r#"{"mode":"env","keys":["environment"],"env_files":["/nix/store/x/.env"],"credential_keys":["DB"]}"#, + ) + .unwrap(); + assert!( + env_with_credential_keys + .validate_shape() + .unwrap_err() + .contains("env mode does not use credential_keys") + ); + + // files mode should reject credential_keys and sockets. + let files_with_sockets: ExecConfig = serde_json::from_str( + r#"{"mode":"files","keys":["environment"],"env_files":["/nix/store/x/.env"],"secrets_dir":"/run/x","sockets":{"DB":"/s.sock"}}"#, + ) + .unwrap(); + assert!( + files_with_sockets + .validate_shape() + .unwrap_err() + .contains("files mode does not use sockets") + ); + + // credentials mode should reject keys, env_files, overload, passthrough. + let creds_with_env_files: ExecConfig = serde_json::from_str( + r#"{"mode":"credentials","credential_keys":["DB"],"env_files":["/nix/store/x/.env"]}"#, + ) + .unwrap(); + assert!( + creds_with_env_files + .validate_shape() + .unwrap_err() + .contains("credentials mode does not use env_files") + ); + + let creds_with_overload: ExecConfig = serde_json::from_str( + r#"{"mode":"credentials","credential_keys":["DB"],"overload":true}"#, + ) + .unwrap(); + assert!( + creds_with_overload + .validate_shape() + .unwrap_err() + .contains("credentials mode does not use overload") + ); + } +} diff --git a/src/keys.rs b/src/keys.rs deleted file mode 100644 index 9fe9343..0000000 --- a/src/keys.rs +++ /dev/null @@ -1,817 +0,0 @@ -//! Key loading, variable name resolution, and ciphertext decryption. -//! -//! All secret material is Zeroized. Decryption is tried with rotation candidates. -//! Plaintext values (from `dotenvx set --plain`) are passed through. -//! -//! Key-naming conventions are abstracted via the `KeyNaming` trait -//! (see `adapter.rs`), so the core logic is adapter-neutral. The `.env` -//! adapter's `KeyNaming::Env` is the first implementation. - -#![allow(missing_docs, missing_debug_implementations)] - -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use zeroize::{Zeroize, Zeroizing}; - -use crate::adapter::KeyNaming; -use crate::config::Entry; - -/// Full variable name (adapter-specific, e.g. DOTENV_PRIVATE_KEY[_]) -/// → candidate secret keys. Multiple candidates per name come from -/// comma-separated rotation convention; decryption tries each in order. -#[derive(Debug)] -pub struct KeyRing(HashMap>>); - -impl KeyRing { - /// Look up candidate keys for a given key variable name. - /// Returns `None` if the variable has no candidates in the ring. - pub fn candidates(&self, var: &str) -> Option<&[Zeroizing<[u8; 32]>]> { - self.0.get(var).map(|v| v.as_slice()) - } - /// Load a `.env.keys` file from `path` and build a key ring. - /// - /// Security invariants enforced on the opened file (all checked on the - /// file descriptor, never the path, to eliminate TOCTOU windows): - /// - `O_NOFOLLOW`: reject a symlink at `path` (defense-in-depth; also - /// closes the credential-source symlink leg of audit finding #2). - /// - `mode & 0o077 == 0`: no group or world read/write/exec. A keys - /// file accidentally `chmod 0644` would expose private keys to every - /// process on the host; fail closed instead. - /// - `st_uid == euid`: the file must be owned by the service user. - pub fn load(path: &Path, naming: KeyNaming) -> Result { - let oflags = - rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC; - let fd = rustix::fs::open(path, oflags, rustix::fs::Mode::empty()).map_err(|e| { - format!( - "keys file {}: {e} (refusing to follow a symlink)", - path.display() - ) - })?; - let stat = rustix::fs::fstat(&fd) - .map_err(|e| format!("keys file {}: fstat: {e}", path.display()))?; - let mode = rustix::fs::Mode::from_bits_truncate(stat.st_mode); - // Reject group or world accessibility. 0o077 masks both RWXG and RWXO. - if mode.intersects(rustix::fs::Mode::RWXG | rustix::fs::Mode::RWXO) { - return Err(format!( - "keys file {}: permissions {:#o} are group/world accessible; expected 0400 or 0600 (fail closed)", - path.display(), - stat.st_mode - )); - } - let euid = rustix::process::geteuid().as_raw(); - if stat.st_uid != euid { - return Err(format!( - "keys file {}: owned by uid {} but postmaster euid is {euid} (fail closed)", - path.display(), - stat.st_uid - )); - } - let file = std::fs::File::from(fd); - let iter = dotenvy::from_read_iter(file); - let mut pairs = Vec::new(); - for item in iter { - let (k, v) = item.map_err(|e| format!("keys file {}: {e}", path.display()))?; - pairs.push((k, Zeroizing::new(v))); - } - Self::from_pairs(pairs, naming).map_err(|e| format!("keys file {}: {e}", path.display())) - } - - /// Core parser shared by every key source: keep keys matching the - /// adapter's key prefix, split comma-separated rotation candidates, - /// hex-decode to 32-byte zeroized keys. Empty result is an error - /// (fail closed). Takes `Zeroizing` values so callers never - /// hand key material as a plain heap String that drops without - /// zeroization. - pub fn from_pairs( - pairs: impl IntoIterator)>, - naming: KeyNaming, - ) -> Result { - let prefix = naming.key_prefix(); - let mut map: HashMap>> = HashMap::new(); - for (name, mut value) in pairs { - if !name.starts_with(prefix) { - value.zeroize(); - continue; - } - let mut keys = Vec::new(); - for part in value.split(',') { - let part = part.trim(); - if part.is_empty() { - continue; - } - let mut raw = - hex::decode(part).map_err(|_| format!("{name}: entry is not valid hex"))?; - if raw.len() != 32 { - raw.zeroize(); - return Err(format!("{name}: expected a 32-byte key")); - } - let mut arr = [0_u8; 32]; - arr.copy_from_slice(&raw); - raw.zeroize(); - keys.push(Zeroizing::new(arr)); - } - value.zeroize(); - if keys.is_empty() { - return Err(format!("{name}: no usable keys")); - } - map.insert(name, keys); - } - if map.is_empty() { - return Err(naming.empty_error()); - } - Ok(KeyRing(map)) - } - - /// Private key variables from the process environment (external - /// provisioning; `key.file = null` in the module). Reads all - /// variables matching the adapter's key prefix. - pub fn from_env(naming: KeyNaming) -> Result { - let prefix = naming.key_prefix(); - Self::from_pairs( - std::env::vars() - .filter(|(k, _)| k.starts_with(prefix)) - .map(|(k, v)| (k, Zeroizing::new(v))), - naming, - ) - .map_err(|e| format!("environment: {e}")) - } - - /// macOS System keychain: one generic-password item per variable, - /// service = `service`, account = the adapter's key variable name. - /// Only the vars actually needed (derived from artifact names) are - /// fetched. - #[cfg(target_os = "macos")] - pub fn from_keychain( - service: &str, - vars: &[String], - naming: KeyNaming, - ) -> Result { - // Audit finding #3: verify the integrity of /usr/bin/security - // before invoking it. On macOS the binary is SIP-protected, but - // verify the code signature at runtime as defense-in-depth rather - // than trusting the path implicitly. - verify_external_binary(Path::new("/usr/bin/security"))?; - let mut pairs = Vec::new(); - for var in vars { - let out = std::process::Command::new("/usr/bin/security") - .args(["find-generic-password", "-s", service, "-a", var, "-w"]) - .output() - .map_err(|e| format!("security: {e}"))?; - if !out.status.success() { - return Err(format!( - "keychain {service}: no item for account {var} (fail closed)" - )); - } - let mut val = Zeroizing::new( - String::from_utf8(out.stdout) - .map_err(|_| format!("keychain {service}/{var}: not UTF-8"))?, - ); - // Zeroizing the trimmed copy: the value is wrapped in Zeroizing - // before being passed to from_pairs, so key material is zeroized - // on drop at every stage. No plain-String window remains. - let trimmed = Zeroizing::new(val.trim().to_string()); - pairs.push((var.clone(), trimmed)); - val.zeroize(); - } - Self::from_pairs(pairs, naming).map_err(|e| format!("keychain {service}: {e}")) - } -} - -/// Verify the integrity of an external binary before invoking it as part -/// of a secret-handling path. This is a defense-in-depth trust boundary -/// check (audit finding #3): the binary path is a trust root, and we -/// verify it has not been tampered with before exec. -/// -/// On macOS: `codesign -v ` verifies the code signature. The binary -/// is additionally SIP-protected, so this catches a hypothetical SIP -/// bypass or a misconfigured environment. -/// -/// On Linux: verify the file is a regular file, owned by root (uid 0), -/// and not world-writable. There is no universal code-signing mechanism -/// on Linux, so ownership and permission checks are the practical -/// defense-in-depth boundary. -pub fn verify_external_binary(path: &Path) -> Result<(), String> { - #[cfg(target_os = "macos")] - { - let out = std::process::Command::new("/usr/bin/codesign") - .args(["-v", path.to_str().unwrap_or("")]) - .output() - .map_err(|e| format!("{}: codesign verification failed: {e}", path.display()))?; - if !out.status.success() { - return Err(format!( - "{}: code signature verification failed (audit finding #3 trust boundary)", - path.display() - )); - } - } - #[cfg(not(target_os = "macos"))] - { - use std::os::unix::fs::MetadataExt; - let md = std::fs::symlink_metadata(path) - .map_err(|e| format!("{}: cannot stat external binary: {e}", path.display()))?; - if md.file_type().is_symlink() { - return Err(format!( - "{}: external binary is a symlink; refusing to trust a symlinked binary", - path.display() - )); - } - if !md.is_file() { - return Err(format!( - "{}: external binary is not a regular file", - path.display() - )); - } - if md.uid() != 0 { - return Err(format!( - "{}: external binary owned by uid {} (expected root/0); refusing to trust", - path.display(), - md.uid() - )); - } - if md.mode() & 0o002 != 0 { - return Err(format!( - "{}: external binary is world-writable; refusing to trust", - path.display() - )); - } - } - Ok(()) -} - -/// Shell-identifier check shared with the Nix module's eval-time isIdent. -pub fn is_ident(k: &str) -> bool { - let mut cs = k.chars(); - matches!(cs.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') - && cs.all(|c| c.is_ascii_alphanumeric() || c == '_') -} - -/// Decrypt one raw value: `encrypted:` via the ring (rotation-aware), -/// anything else passed through as opaque bytes (plaintext passthrough). -/// Base64 payload may contain ASCII whitespace (spaces, newlines, tabs, CR), -/// matching the lenient decoding for line-wrapped values. -pub fn decrypt_value( - env_file: &Path, - key: &str, - raw: &str, - ring: &KeyRing, - naming: KeyNaming, -) -> Result>, String> { - let Some(b64) = raw.strip_prefix("encrypted:") else { - return Ok(Zeroizing::new(raw.as_bytes().to_vec())); - }; - let ciphertext: Zeroizing> = { - if b64.chars().any(|c| c.is_ascii_whitespace()) { - // Zeroizing: b64_clean holds derived ciphertext material (base64 - // of the encrypted blob). Zeroize on drop for defense-in-depth, - // matching the crate's hygiene posture on key and plaintext buffers. - let b64_clean = Zeroizing::new( - b64.chars() - .filter(|c| !c.is_ascii_whitespace()) - .collect::(), - ); - Zeroizing::new( - BASE64 - .decode(&*b64_clean) - .map_err(|_| format!("{}: {key} is not valid base64", env_file.display()))?, - ) - } else { - Zeroizing::new( - BASE64 - .decode(b64) - .map_err(|_| format!("{}: {key} is not valid base64", env_file.display()))?, - ) - } - }; - let var = naming.key_var_for(env_file)?; - let candidates = ring - .0 - .get(&var) - .ok_or_else(|| format!("key ring has no {var} entry"))?; - for sk in candidates { - if let Ok(pt) = ecies::decrypt(&sk[..], &ciphertext) { - return Ok(Zeroizing::new(pt)); - } - } - Err(format!( - "{}: no key under {var} decrypts {key} ({} candidate(s) tried)", - env_file.display(), - candidates.len() - )) -} - -/// Resolve a single entry using the key ring. -/// Supports both encrypted values and plaintext passthrough. -pub fn resolve( - entry: &Entry, - ring: &KeyRing, - naming: KeyNaming, -) -> Result>, String> { - let iter = dotenvy::from_path_iter(&entry.env_file) - .map_err(|e| format!("{}: {e}", entry.env_file.display()))?; - // Last assignment wins, matching dotenv semantics within one file. - let mut found: Option> = None; - for item in iter { - let (k, v) = item.map_err(|e| format!("{}: {e}", entry.env_file.display()))?; - let v = Zeroizing::new(v); - if k == entry.key { - found = Some(v); - } - } - let raw = - found.ok_or_else(|| format!("{}: no key named {}", entry.env_file.display(), entry.key))?; - - decrypt_value(&entry.env_file, &entry.key, &raw, ring, naming) -} - -/// Resolved (name, secret-bytes) pairs, in injection order. -pub type ResolvedVars = Vec<(String, Zeroizing>)>; - -/// Resolve every key across `files` per the precedence contract -/// (see docs/src/launcher-contract.md): within one file the LAST assignment -/// wins (dotenv semantics); across files the FIRST file defining a key wins -/// unless `overload`; keys already present in the process environment are -/// skipped unless `overload`. Adapter metadata keys are dropped; -/// non-identifier keys are a hard error (they become env names/filenames). -pub fn resolve_all( - files: &[PathBuf], - ring: &KeyRing, - overload: bool, - strict: bool, - env_has: &dyn Fn(&str) -> bool, - naming: KeyNaming, -) -> Result { - let metadata_prefix = naming.metadata_prefix(); - let mut order: Vec = Vec::new(); - let mut merged: HashMap)> = HashMap::new(); - for f in files { - let iter = dotenvy::from_path_iter(f).map_err(|e| format!("{}: {e}", f.display()))?; - let mut file_vals: HashMap> = HashMap::new(); - let mut file_order: Vec = Vec::new(); - for item in iter { - let (k, v) = item.map_err(|e| format!("{}: {e}", f.display()))?; - let v = Zeroizing::new(v); - if let Some(prefix) = metadata_prefix - && k.starts_with(prefix) - { - continue; - } - if !is_ident(&k) { - return Err(format!( - "{}: refusing non-identifier key {k:?}", - f.display() - )); - } - if !file_vals.contains_key(&k) { - file_order.push(k.clone()); - } - file_vals.insert(k, v); // last assignment within a file wins - } - for k in file_order { - let v = file_vals.remove(&k).expect("key recorded in file_order"); - if let Some(slot) = merged.get_mut(&k) { - if overload { - *slot = (f.clone(), v); // later file wins under overload - } - } else { - order.push(k.clone()); - merged.insert(k, (f.clone(), v)); - } - } - } - let mut out = Vec::new(); - for k in order { - if !overload && env_has(&k) { - continue; // process environment wins - } - let (file, raw) = merged.remove(&k).expect("key recorded in order"); - match decrypt_value(&file, &k, &raw, ring, naming) { - Ok(v) => out.push((k, v)), - Err(e) if strict => return Err(e), - Err(e) => crate::server::log(&format!("non-strict: skipping {k}: {e}")), - } - } - Ok(out) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::panic, unused_qualifications)] -pub mod tests { - use super::*; - use crate::adapter::KeyNaming; - - /// Test secret key hex — never use outside tests. - pub const TEST_SK_HEX: &str = - "1111111111111111111111111111111111111111111111111111111111111111"; - - /// Build a test key ring with the test secret key under - /// `POSTMASTER_KEY` (for bundle adapter tests). - pub fn make_test_ring() -> KeyRing { - let pairs = vec![( - "POSTMASTER_KEY".to_string(), - Zeroizing::new(TEST_SK_HEX.to_string()), - )]; - KeyRing::from_pairs(pairs, KeyNaming::Bundle).unwrap() - } - use std::io::Write; - use std::path::Path; - - fn with_temp_keys(content: &str, f: impl FnOnce(&Path)) { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "postmaster-test-keys-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - { - let mut f = std::fs::File::create(&path).unwrap(); - writeln!(f, "{}", content).unwrap(); - } - // KeyRing::load now enforces 0600-or-stricter permissions (audit - // finding #1). The temp file must pass that check. - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - f(&path); - let _ = std::fs::remove_file(&path); - } - - fn tdir(tag: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("pm-keys-{tag}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&d); - std::fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn key_var_for_basic() { - assert_eq!( - KeyNaming::Env - .key_var_for(std::path::Path::new(".env")) - .unwrap(), - "DOTENV_PRIVATE_KEY" - ); - assert_eq!( - KeyNaming::Env - .key_var_for(std::path::Path::new(".env.production")) - .unwrap(), - "DOTENV_PRIVATE_KEY_PRODUCTION" - ); - assert_eq!( - KeyNaming::Env - .key_var_for(std::path::Path::new(".env.staging_v2")) - .unwrap(), - "DOTENV_PRIVATE_KEY_STAGING_V2" - ); - } - - #[test] - fn load_rotated_keys_and_plaintext_env() { - // Create a real .env file with a plaintext value (simulating --plain) - let env_dir = std::env::temp_dir(); - let env_path = env_dir.join(format!( - "postmaster-test-env-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - { - let mut f = std::fs::File::create(&env_path).unwrap(); - writeln!(f, "PLAIN=hello-world").unwrap(); - } - - let entry = Entry { - env_file: env_path.clone(), - key: "PLAIN".to_string(), - peer_user: None, - }; - // Ring can be empty for plaintext path - let ring = KeyRing(HashMap::new()); - let result = resolve(&entry, &ring, KeyNaming::Env).unwrap(); - assert_eq!(&*result, b"hello-world"); - - let _ = std::fs::remove_file(&env_path); - } - - #[test] - fn from_pairs_filters_and_rotates() { - let ring = KeyRing::from_pairs( - [ - ("UNRELATED".to_string(), Zeroizing::new("x".to_string())), - ( - "DOTENV_PRIVATE_KEY_PRODUCTION".to_string(), - Zeroizing::new(format!("{},{}", "22".repeat(32), "33".repeat(32))), - ), - ], - KeyNaming::Env, - ) - .unwrap(); - assert_eq!(ring.0.len(), 1); - assert_eq!(ring.0["DOTENV_PRIVATE_KEY_PRODUCTION"].len(), 2); - } - - #[test] - fn from_pairs_empty_is_error() { - assert!( - KeyRing::from_pairs( - [("A".to_string(), Zeroizing::new("b".to_string()))], - KeyNaming::Env - ) - .is_err() - ); - } - - #[test] - fn load_rejects_bad_hex() { - let content = "DOTENV_PRIVATE_KEY=not-hex\n"; - with_temp_keys(content, |p| match KeyRing::load(p, KeyNaming::Env) { - Err(e) if e.contains("not valid hex") => {} - other => panic!("expected bad hex error, got {other:?}"), - }); - } - - #[test] - fn load_rejects_world_readable_keys_file() { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "postmaster-test-keys-worldread-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - { - let mut f = std::fs::File::create(&path).unwrap(); - writeln!(f, "DOTENV_PRIVATE_KEY={}", "11".repeat(32)).unwrap(); - } - // 0644: world-readable — must be rejected. - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); - match KeyRing::load(&path, KeyNaming::Env) { - Err(e) if e.contains("group/world accessible") => {} - other => panic!("expected group/world permission error, got {other:?}"), - } - let _ = std::fs::remove_file(&path); - } - - #[test] - fn load_rejects_group_readable_keys_file() { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "postmaster-test-keys-groupread-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - { - let mut f = std::fs::File::create(&path).unwrap(); - writeln!(f, "DOTENV_PRIVATE_KEY={}", "11".repeat(32)).unwrap(); - } - // 0640: group-readable — must be rejected. - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); - match KeyRing::load(&path, KeyNaming::Env) { - Err(e) if e.contains("group/world accessible") => {} - other => panic!("expected group permission error, got {other:?}"), - } - let _ = std::fs::remove_file(&path); - } - - #[test] - fn load_rejects_symlink_keys_file() { - let dir = tdir("symlink-keys"); - let real = dir.join("real-keys"); - std::fs::write(&real, format!("DOTENV_PRIVATE_KEY={}\n", "11".repeat(32))).unwrap(); - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap(); - let link = dir.join(".env.keys"); - std::os::unix::fs::symlink(&real, &link).unwrap(); - match KeyRing::load(&link, KeyNaming::Env) { - Err(e) if e.contains("symlink") => {} - other => panic!("expected symlink rejection error, got {other:?}"), - } - } - - #[test] - fn load_accepts_0600_keys_file() { - // Sanity: a properly locked-down keys file (0600, owned by euid) - // loads normally. This guards against false positives from the - // permission and ownership checks. - let content = format!("DOTENV_PRIVATE_KEY={}\n", "11".repeat(32)); - with_temp_keys(&content, |p| { - KeyRing::load(p, KeyNaming::Env).expect("0600 keys file owned by euid should load"); - }); - } - - #[test] - fn load_accepts_0400_keys_file() { - use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "postmaster-test-keys-0400-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - { - let mut f = std::fs::File::create(&path).unwrap(); - writeln!(f, "DOTENV_PRIVATE_KEY={}", "11".repeat(32)).unwrap(); - } - // 0400: read-only by owner — stricter than 0600, must pass. - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); - KeyRing::load(&path, KeyNaming::Env).expect("0400 keys file owned by euid should load"); - let _ = std::fs::remove_file(&path); - } - - #[test] - #[cfg(target_os = "macos")] - fn verify_external_binary_accepts_codesigned_binary() { - // /usr/bin/security is Apple-signed and SIP-protected; the codesign - // verification must pass. This guards against a hypothetical SIP - // bypass or a misconfigured environment where the binary has been - // replaced. - verify_external_binary(std::path::Path::new("/usr/bin/security")) - .expect("codesigned system binary should pass verification"); - } - - #[test] - #[cfg(target_os = "macos")] - fn verify_external_binary_rejects_unsigned_file() { - // A temp file with no code signature must be rejected by codesign -v. - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "postmaster-test-unsigned-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::write(&path, b"not a signed binary").unwrap(); - let result = verify_external_binary(&path); - let _ = std::fs::remove_file(&path); - assert!( - result.is_err(), - "unsigned file should fail code signature verification" - ); - } - - #[test] - #[cfg(not(target_os = "macos"))] - fn verify_external_binary_rejects_non_root_owned() { - // On Linux, a non-root-owned binary in /tmp must be rejected. - let dir = std::env::temp_dir(); - let path = dir.join(format!( - "postmaster-test-binary-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - std::fs::write(&path, b"fake binary").unwrap(); - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); - let result = verify_external_binary(&path); - let _ = std::fs::remove_file(&path); - assert!(result.is_err(), "non-root-owned binary should be rejected"); - } - - #[test] - fn is_ident_rules() { - assert!(is_ident("DATABASE_URL") && is_ident("_x9")); - assert!(!is_ident("9x") && !is_ident("a-b") && !is_ident("") && !is_ident("a b")); - } - - #[test] - fn resolve_all_precedence() { - let d = tdir("prec"); - std::fs::write( - d.join(".env"), - "A=first\nB=one\nB=two\nDOTENV_PUBLIC_KEY=zz\n", - ) - .unwrap(); - std::fs::write(d.join(".env.local"), "A=second\nC=three\n").unwrap(); - let files = [d.join(".env"), d.join(".env.local")]; - let ring = KeyRing(HashMap::new()); - - // default: first file wins; within a file last assignment wins; - // process-env presence suppresses; DOTENV_* filtered. - let out = resolve_all(&files, &ring, false, true, &|k| k == "C", KeyNaming::Env).unwrap(); - let got: Vec<(&str, &[u8])> = out.iter().map(|(k, v)| (k.as_str(), &v[..])).collect(); - assert_eq!( - got, - vec![("A", b"first".as_slice()), ("B", b"two".as_slice())] - ); - - // overload: later file wins, env presence ignored. - let out = resolve_all(&files, &ring, true, true, &|k| k == "C", KeyNaming::Env).unwrap(); - let got: Vec<(&str, &[u8])> = out.iter().map(|(k, v)| (k.as_str(), &v[..])).collect(); - assert_eq!( - got, - vec![ - ("A", b"second".as_slice()), - ("B", b"two".as_slice()), - ("C", b"three".as_slice()) - ] - ); - } - - #[test] - fn resolve_all_rejects_non_identifier() { - // dotenvy rejects keys starting with `-` or digit during parsing, but accepts `.` - // in subsequent characters (e.g., BAD.KEY=x parses fine). Our is_ident check rejects - // `.` in all positions, so a dotted key exercises the rejection branch in resolve_all. - let d = tdir("ident"); - std::fs::write(d.join(".env"), "BAD.KEY=x\n").unwrap(); - let ring = KeyRing(HashMap::new()); - let e = resolve_all( - &[d.join(".env")], - &ring, - false, - true, - &|_| false, - KeyNaming::Env, - ) - .unwrap_err(); - assert!(e.contains("non-identifier"), "{e}"); - } - - #[test] - fn resolve_all_strict_fails_on_undecryptable() { - let d = tdir("strict"); - std::fs::write(d.join(".env"), "A=encrypted:!!!notbase64\n").unwrap(); - let ring = KeyRing(HashMap::new()); - assert!( - resolve_all( - &[d.join(".env")], - &ring, - false, - true, - &|_| false, - KeyNaming::Env - ) - .is_err() - ); - // non-strict: skipped, not served wrong. - let out = resolve_all( - &[d.join(".env")], - &ring, - false, - false, - &|_| false, - KeyNaming::Env, - ) - .unwrap(); - assert!(out.is_empty()); - } - - #[test] - fn from_env_loads_and_fails_closed() { - // from_env reads ALL DOTENV_PRIVATE_KEY* vars from the process - // environment and delegates to from_pairs. This is a single test - // (not two) because env mutation is global and tests run in - // parallel — two tests mutating DOTENV_PRIVATE_KEY* simultaneously - // would race. Using a unique suffix to avoid collision with any - // ambient vars in the test runner. - let var_name = "DOTENV_PRIVATE_KEY_TESTFROMENV"; - let key_hex = "aa".repeat(32); - - // Positive path: set a valid key, from_env succeeds. - // SAFETY: set_var/remove_var are unsafe on edition 2024 (not - // thread-safe). This test does not spawn threads and the var name - // is unique, so no concurrent test mutates the same var. - unsafe { - std::env::set_var(var_name, &key_hex); - } - let ring = KeyRing::from_env(KeyNaming::Env); - unsafe { - std::env::remove_var(var_name); - } - let ring = ring.expect("from_env should succeed with a valid key set"); - let candidates = ring - .0 - .get(var_name) - .expect("ring should contain the key we set"); - assert_eq!(candidates.len(), 1, "exactly one candidate expected"); - assert_eq!(&candidates[0][..], &[0xaa; 32]); - - // Negative path: set an invalid hex value, from_env fails closed - // with an "environment:" prefixed error. - unsafe { - std::env::set_var(var_name, "not-valid-hex-at-all"); - } - let err = KeyRing::from_env(KeyNaming::Env); - unsafe { - std::env::remove_var(var_name); - } - let err = err.expect_err("from_env should fail on invalid hex"); - assert!( - err.starts_with("environment:"), - "expected 'environment:' prefix, got: {err}" - ); - - // Also verify the from_pairs empty-error path directly: no - // DOTENV_PRIVATE_KEY* entries at all should be an error. - assert!( - KeyRing::from_pairs( - [( - "NOT_A_DOTENV_KEY".to_string(), - Zeroizing::new("x".to_string()) - )], - KeyNaming::Env, - ) - .is_err() - ); - } -} diff --git a/src/keys/env.rs b/src/keys/env.rs new file mode 100644 index 0000000..f198b5e --- /dev/null +++ b/src/keys/env.rs @@ -0,0 +1,145 @@ +//! Process-environment key source (external provisioning). + +use zeroize::Zeroizing; + +use super::KeyRing; +use crate::adapter::KeyNaming; + +/// True for exactly `PREFIX` or `PREFIX_`. A bare +/// prefix match would slurp unrelated variables like +/// `DOTENV_PRIVATE_KEYFOO` as key material (audit 2026-08-02 A16). +pub(crate) fn is_key_var_name(prefix: &str, name: &str) -> bool { + name == prefix + || name + .strip_prefix(prefix) + .is_some_and(|rest| rest.len() > 1 && rest.starts_with('_')) +} + +/// Implementation of [`KeyRing::from_env`]; the contract is documented on +/// the method. +pub(super) fn from_env(namings: &[KeyNaming]) -> Result { + KeyRing::from_pairs( + std::env::vars() + .filter(|(k, _)| namings.iter().any(|n| is_key_var_name(n.key_prefix(), k))) + .map(|(k, v)| (k, Zeroizing::new(v))), + namings, + ) + .map_err(|e| format!("environment: {e}")) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, unused_qualifications)] +mod tests { + use super::*; + use crate::test_util::ENV_LOCK; + + #[test] + fn is_key_var_name_rules() { + // audit 2026-08-02 A16: only `PREFIX` or `PREFIX_` match; + // prefix-substring collisions are rejected. + let p = "DOTENV_PRIVATE_KEY"; + assert!(is_key_var_name(p, "DOTENV_PRIVATE_KEY")); + assert!(is_key_var_name(p, "DOTENV_PRIVATE_KEY_PRODUCTION")); + assert!(!is_key_var_name(p, "DOTENV_PRIVATE_KEYFOO")); + assert!(!is_key_var_name(p, "DOTENV_PRIVATE_KEY_")); // empty suffix + assert!(!is_key_var_name(p, "DOTENV_PRIVATE_KE")); + assert!(!is_key_var_name(p, "XDOTENV_PRIVATE_KEY")); + } + + #[test] + fn from_env_loads_and_fails_closed() { + // from_env reads ALL DOTENV_PRIVATE_KEY* vars from the process + // environment and delegates to from_pairs. This is a single test + // (not two) because env mutation is global; the shared ENV_LOCK + // serializes it against every other env-mutating test. Using a + // unique suffix to avoid collision with any ambient vars in the + // test runner. + // + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + let var_name = "DOTENV_PRIVATE_KEY_TESTFROMENV"; + let key_hex = "aa".repeat(32); + + // Positive path: set a valid key, from_env succeeds. + unsafe { + std::env::set_var(var_name, &key_hex); + } + let ring = KeyRing::from_env(&[KeyNaming::Env]); + unsafe { + std::env::remove_var(var_name); + } + let ring = ring.expect("from_env should succeed with a valid key set"); + let candidates = ring + .0 + .get(var_name) + .expect("ring should contain the key we set"); + assert_eq!(candidates.len(), 1, "exactly one candidate expected"); + assert_eq!(&candidates[0][..], &[0xaa; 32]); + + // Negative path: set an invalid hex value, from_env fails closed + // with an "environment:" prefixed error. + unsafe { + std::env::set_var(var_name, "not-valid-hex-at-all"); + } + let err = KeyRing::from_env(&[KeyNaming::Env]); + unsafe { + std::env::remove_var(var_name); + } + let err = err.expect_err("from_env should fail on invalid hex"); + assert!( + err.starts_with("environment:"), + "expected 'environment:' prefix, got: {err}" + ); + + // Also verify the from_pairs empty-error path directly: no + // DOTENV_PRIVATE_KEY* entries at all should be an error. + assert!( + KeyRing::from_pairs( + [( + "NOT_A_DOTENV_KEY".to_string(), + Zeroizing::new("x".to_string()) + )], + &[KeyNaming::Env], + ) + .is_err() + ); + } + + #[test] + fn from_env_admits_only_the_required_namings() { + // The bundle-ring gap fix: with Bundle required, POSTMASTER_KEY + // enters the ring; when it is not required, the same variable is + // NOT admitted (least privilege). + // + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + let dotenv_var = "DOTENV_PRIVATE_KEY_TESTNAMINGS"; + let bundle_var = "POSTMASTER_KEY_TESTNAMINGS"; + let key_hex = "aa".repeat(32); + unsafe { + std::env::set_var(dotenv_var, &key_hex); + std::env::set_var(bundle_var, &key_hex); + } + let both = KeyRing::from_env(&[KeyNaming::Env, KeyNaming::Bundle]); + let bundle_only = KeyRing::from_env(&[KeyNaming::Bundle]); + unsafe { + std::env::remove_var(dotenv_var); + std::env::remove_var(bundle_var); + } + + let both = both.expect("both namings required should load"); + assert!(both.candidates(dotenv_var).is_some()); + assert!(both.candidates(bundle_var).is_some()); + + let bundle_only = bundle_only.expect("bundle naming alone should load"); + assert!( + bundle_only.candidates(dotenv_var).is_none(), + "an unrequired naming must not enter the ring" + ); + assert!(bundle_only.candidates(bundle_var).is_some()); + } +} diff --git a/src/keys/keychain.rs b/src/keys/keychain.rs new file mode 100644 index 0000000..64892c2 --- /dev/null +++ b/src/keys/keychain.rs @@ -0,0 +1,35 @@ +//! macOS System keychain key source. + +use zeroize::Zeroizing; + +use super::KeyRing; +use crate::adapter::KeyNaming; + +/// Implementation of [`KeyRing::from_keychain`]; the contract is +/// documented on the method. +#[cfg(target_os = "macos")] +pub(super) fn from_keychain( + service: &str, + vars: &[String], + namings: &[KeyNaming], +) -> Result { + // Reads go through the Security.framework API, not the + // /usr/bin/security CLI (F1 direction): no subprocess, no argv, + // and no stdout buffer holding key material — the UTF-8-failure + // path validates from a borrow so the error never carries the + // bytes (audit 2026-08-02 A17). + let mut pairs = Vec::new(); + for var in vars { + let bytes = Zeroizing::new( + security_framework::passwords::get_generic_password(service, var).map_err(|_| { + format!("keychain {service}: no item for account {var} (fail closed)") + })?, + ); + let s = std::str::from_utf8(&bytes[..]) + .map_err(|_| format!("keychain {service}/{var}: not UTF-8"))?; + // from_pairs trims each comma-separated candidate itself, so + // no separate trim is needed here. + pairs.push((var.clone(), Zeroizing::new(s.to_string()))); + } + KeyRing::from_pairs(pairs, namings).map_err(|e| format!("keychain {service}: {e}")) +} diff --git a/src/keys/load.rs b/src/keys/load.rs new file mode 100644 index 0000000..63c6d57 --- /dev/null +++ b/src/keys/load.rs @@ -0,0 +1,154 @@ +//! `.env.keys` file loading with on-fd stat hardening (no TOCTOU window). + +use std::path::Path; + +use zeroize::Zeroizing; + +use super::KeyRing; +use crate::adapter::KeyNaming; + +/// Implementation of [`KeyRing::load`]; the contract is documented on the +/// method. +pub(super) fn load(path: &Path, namings: &[KeyNaming]) -> Result { + let oflags = + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC; + let fd = rustix::fs::open(path, oflags, rustix::fs::Mode::empty()).map_err(|e| { + format!( + "keys file {}: {e} (refusing to follow a symlink)", + path.display() + ) + })?; + let stat = + rustix::fs::fstat(&fd).map_err(|e| format!("keys file {}: fstat: {e}", path.display()))?; + let mode = rustix::fs::Mode::from_bits_truncate(stat.st_mode); + // Reject group or world accessibility. 0o077 masks both RWXG and RWXO. + if mode.intersects(rustix::fs::Mode::RWXG | rustix::fs::Mode::RWXO) { + return Err(format!( + "keys file {}: permissions {:#o} are group/world accessible; expected 0400 or 0600 (fail closed)", + path.display(), + stat.st_mode + )); + } + let euid = rustix::process::geteuid().as_raw(); + if stat.st_uid != euid { + return Err(format!( + "keys file {}: owned by uid {} but postmaster euid is {euid} (fail closed)", + path.display(), + stat.st_uid + )); + } + let file = std::fs::File::from(fd); + let iter = dotenvy::from_read_iter(file); + let mut pairs = Vec::new(); + for item in iter { + let (k, v) = item.map_err(|e| format!("keys file {}: {e}", path.display()))?; + pairs.push((k, Zeroizing::new(v))); + } + KeyRing::from_pairs(pairs, namings).map_err(|e| format!("keys file {}: {e}", path.display())) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, unused_qualifications)] +mod tests { + use super::*; + use crate::keys::tests::{tdir, with_temp_keys}; + use std::io::Write; + + #[test] + fn load_rejects_bad_hex() { + let content = "DOTENV_PRIVATE_KEY=not-hex\n"; + with_temp_keys(content, |p| match KeyRing::load(p, &[KeyNaming::Env]) { + Err(e) if e.contains("not valid hex") => {} + other => panic!("expected bad hex error, got {other:?}"), + }); + } + + #[test] + fn load_rejects_world_readable_keys_file() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "postmaster-test-keys-worldread-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + { + let mut f = std::fs::File::create(&path).unwrap(); + writeln!(f, "DOTENV_PRIVATE_KEY={}", "11".repeat(32)).unwrap(); + } + // 0644: world-readable — must be rejected. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + match KeyRing::load(&path, &[KeyNaming::Env]) { + Err(e) if e.contains("group/world accessible") => {} + other => panic!("expected group/world permission error, got {other:?}"), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_rejects_group_readable_keys_file() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "postmaster-test-keys-groupread-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + { + let mut f = std::fs::File::create(&path).unwrap(); + writeln!(f, "DOTENV_PRIVATE_KEY={}", "11".repeat(32)).unwrap(); + } + // 0640: group-readable — must be rejected. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).unwrap(); + match KeyRing::load(&path, &[KeyNaming::Env]) { + Err(e) if e.contains("group/world accessible") => {} + other => panic!("expected group permission error, got {other:?}"), + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn load_rejects_symlink_keys_file() { + let dir = tdir("symlink-keys"); + let real = dir.join("real-keys"); + std::fs::write(&real, format!("DOTENV_PRIVATE_KEY={}\n", "11".repeat(32))).unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o600)).unwrap(); + let link = dir.join(".env.keys"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + match KeyRing::load(&link, &[KeyNaming::Env]) { + Err(e) if e.contains("symlink") => {} + other => panic!("expected symlink rejection error, got {other:?}"), + } + } + + #[test] + fn load_accepts_0600_keys_file() { + // Sanity: a properly locked-down keys file (0600, owned by euid) + // loads normally. This guards against false positives from the + // permission and ownership checks. + let content = format!("DOTENV_PRIVATE_KEY={}\n", "11".repeat(32)); + with_temp_keys(&content, |p| { + KeyRing::load(p, &[KeyNaming::Env]).expect("0600 keys file owned by euid should load"); + }); + } + + #[test] + fn load_accepts_0400_keys_file() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "postmaster-test-keys-0400-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + { + let mut f = std::fs::File::create(&path).unwrap(); + writeln!(f, "DOTENV_PRIVATE_KEY={}", "11".repeat(32)).unwrap(); + } + // 0400: read-only by owner — stricter than 0600, must pass. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); + KeyRing::load(&path, &[KeyNaming::Env]).expect("0400 keys file owned by euid should load"); + let _ = std::fs::remove_file(&path); + } +} diff --git a/src/keys/mod.rs b/src/keys/mod.rs new file mode 100644 index 0000000..b86be33 --- /dev/null +++ b/src/keys/mod.rs @@ -0,0 +1,627 @@ +//! Key loading, variable name resolution, and ciphertext decryption. +//! +//! All secret material is Zeroized. Decryption is tried with rotation candidates. +//! Plaintext values (from `dotenvx set --plain`) are passed through. +//! +//! Key-naming conventions are abstracted via the `KeyNaming` trait +//! (see `adapter/mod.rs`), so the core logic is adapter-neutral. The `.env` +//! adapter's `KeyNaming::Env` is the first implementation. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use zeroize::{Zeroize, Zeroizing}; + +use crate::adapter::KeyNaming; +use crate::config::Entry; + +mod env; +#[cfg(target_os = "macos")] +mod keychain; +mod load; +pub mod provisioning; +pub mod source; +mod verify; + +/// Re-exported so `crate::keys::verify_external_binary` keeps resolving. +pub use verify::verify_external_binary; + +/// The exact predicate `KeyRing::from_env` admits, re-exported so setup +/// verification accepts precisely the variables resolution would load +/// (audit 2026-08-02 A16). +pub(crate) use env::is_key_var_name; + +/// Full variable name (adapter-specific, e.g. DOTENV_PRIVATE_KEY[_]) +/// → candidate secret keys. Multiple candidates per name come from +/// comma-separated rotation convention; decryption tries each in order. +#[derive(Debug)] +pub struct KeyRing(HashMap>>); + +impl KeyRing { + /// Look up candidate keys for a given key variable name. + /// Returns `None` if the variable has no candidates in the ring. + pub fn candidates(&self, var: &str) -> Option<&[Zeroizing<[u8; 32]>]> { + self.0.get(var).map(|v| v.as_slice()) + } + + /// Load a `.env.keys` file from `path` and build a key ring. + /// + /// Security invariants enforced on the opened file (all checked on the + /// file descriptor, never the path, to eliminate TOCTOU windows): + /// - `O_NOFOLLOW`: reject a symlink at `path` (defense-in-depth; also + /// closes the credential-source symlink leg of audit finding #2). + /// - `mode & 0o077 == 0`: no group or world read/write/exec. A keys + /// file accidentally `chmod 0644` would expose private keys to every + /// process on the host; fail closed instead. + /// - `st_uid == euid`: the file must be owned by the service user. + /// + /// `namings` is the required adapter set; entries under other + /// prefixes are dropped (and zeroized) at parse time. + pub fn load(path: &Path, namings: &[KeyNaming]) -> Result { + load::load(path, namings) + } + + /// Core parser shared by every key source: keep keys matching any of + /// the required adapters' key prefixes, split comma-separated rotation + /// candidates, hex-decode to 32-byte zeroized keys. Empty result is an + /// error (fail closed). Takes `Zeroizing` values so callers + /// never hand key material as a plain heap String that drops without + /// zeroization. `namings` is the set the config's artifacts require + /// (see `ExecConfig::required_namings`) — the ring admits exactly + /// those prefixes, no more (least privilege on key material). + pub fn from_pairs( + pairs: impl IntoIterator)>, + namings: &[KeyNaming], + ) -> Result { + let mut map: HashMap>> = HashMap::new(); + for (name, mut value) in pairs { + if !namings.iter().any(|n| name.starts_with(n.key_prefix())) { + value.zeroize(); + continue; + } + let mut keys = Vec::new(); + for part in value.split(',') { + let part = part.trim(); + if part.is_empty() { + continue; + } + let mut raw = + hex::decode(part).map_err(|_| format!("{name}: entry is not valid hex"))?; + if raw.len() != 32 { + raw.zeroize(); + return Err(format!("{name}: expected a 32-byte key")); + } + let mut arr = [0_u8; 32]; + arr.copy_from_slice(&raw); + raw.zeroize(); + keys.push(Zeroizing::new(arr)); + } + value.zeroize(); + if keys.is_empty() { + return Err(format!("{name}: no usable keys")); + } + map.insert(name, keys); + } + if map.is_empty() { + return Err(empty_error(namings)); + } + Ok(KeyRing(map)) + } + + /// Private key variables from the process environment (external + /// provisioning; `key.file = null` in the module). Reads all + /// variables matching any required adapter's key prefix. + pub fn from_env(namings: &[KeyNaming]) -> Result { + env::from_env(namings) + } + + /// macOS System keychain: one generic-password item per variable, + /// service = `service`, account = the adapter's key variable name. + /// Only the vars actually needed (derived from artifact names) are + /// fetched. + #[cfg(target_os = "macos")] + pub fn from_keychain( + service: &str, + vars: &[String], + namings: &[KeyNaming], + ) -> Result { + keychain::from_keychain(service, vars, namings) + } +} + +/// Human-readable empty-ring error for a naming set: a single naming +/// keeps its adapter-specific message; multiple namings join them. +fn empty_error(namings: &[KeyNaming]) -> String { + match namings { + [] => "no key material admitted (empty naming set)".to_string(), + [single] => single.empty_error(), + many => format!( + "no {}", + many.iter() + .map(|n| n.empty_error().trim_start_matches("no ").to_string()) + .collect::>() + .join(" or ") + ), + } +} + +/// Shell-identifier check shared with the Nix module's eval-time isIdent. +pub fn is_ident(k: &str) -> bool { + let mut cs = k.chars(); + matches!(cs.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && cs.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Decrypt one raw value: `encrypted:` via the ring (rotation-aware), +/// anything else passed through as opaque bytes (plaintext passthrough). +/// Base64 payload may contain ASCII whitespace (spaces, newlines, tabs, CR), +/// matching the lenient decoding for line-wrapped values. +pub fn decrypt_value( + env_file: &Path, + key: &str, + raw: &str, + ring: &KeyRing, + naming: KeyNaming, +) -> Result>, String> { + let Some(b64) = raw.strip_prefix("encrypted:") else { + return Ok(Zeroizing::new(raw.as_bytes().to_vec())); + }; + let ciphertext: Zeroizing> = { + if b64.chars().any(|c| c.is_ascii_whitespace()) { + // Zeroizing: b64_clean holds derived ciphertext material (base64 + // of the encrypted blob). Zeroize on drop for defense-in-depth, + // matching the crate's hygiene posture on key and plaintext buffers. + let b64_clean = Zeroizing::new( + b64.chars() + .filter(|c| !c.is_ascii_whitespace()) + .collect::(), + ); + Zeroizing::new( + BASE64 + .decode(&*b64_clean) + .map_err(|_| format!("{}: {key} is not valid base64", env_file.display()))?, + ) + } else { + Zeroizing::new( + BASE64 + .decode(b64) + .map_err(|_| format!("{}: {key} is not valid base64", env_file.display()))?, + ) + } + }; + let var = naming.key_var_for(env_file)?; + let candidates = ring + .0 + .get(&var) + .ok_or_else(|| format!("key ring has no {var} entry"))?; + for sk in candidates { + if let Ok(pt) = ecies::decrypt(&sk[..], &ciphertext) { + return Ok(Zeroizing::new(pt)); + } + } + // No candidate count in the error text — the rotation-set size is + // not something errors should leak (audit 2026-08-02 A15). + Err(format!( + "{}: no key under {var} decrypts {key}", + env_file.display() + )) +} + +/// Resolve a single entry using the key ring. +/// Supports both encrypted values and plaintext passthrough. +pub fn resolve( + entry: &Entry, + ring: &KeyRing, + naming: KeyNaming, +) -> Result>, String> { + // O_NOFOLLOW on the ciphertext open (audit 2026-08-02 A13): refuse + // to read through a planted symlink. + let fd = rustix::fs::open( + &entry.env_file, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|e| { + format!( + "{}: {e} (refusing to follow a symlink)", + entry.env_file.display() + ) + })?; + let iter = dotenvy::from_read_iter(std::fs::File::from(fd)); + // Last assignment wins, matching dotenv semantics within one file. + let mut found: Option> = None; + for item in iter { + let (k, v) = item.map_err(|e| format!("{}: {e}", entry.env_file.display()))?; + let v = Zeroizing::new(v); + if k == entry.key { + found = Some(v); + } + } + let raw = + found.ok_or_else(|| format!("{}: no key named {}", entry.env_file.display(), entry.key))?; + + decrypt_value(&entry.env_file, &entry.key, &raw, ring, naming) +} + +/// Resolved (name, secret-bytes) pairs, in injection order. +pub type ResolvedVars = Vec<(String, Zeroizing>)>; + +/// Outcome of a [`resolve_all`] pass. +#[derive(Debug)] +pub struct Resolution { + /// Resolved (name, plaintext) pairs in injection order. + pub resolved: ResolvedVars, + /// `(name, error)` for each encrypted value that could not be + /// decrypted and was skipped under `strict: false`. Recorded so + /// `--verify-keys` can fail closed on a partial key rotation + /// (audit 2026-08-02 A3). Always empty under `strict: true`, where + /// the first failure aborts the whole pass instead. + pub failed: Vec<(String, String)>, +} + +/// Resolve every key across `files` per the precedence contract +/// (see docs/src/launcher-contract.md): within one file the LAST assignment +/// wins (dotenv semantics); across files the FIRST file defining a key wins +/// unless `overload`; keys already present in the process environment are +/// skipped unless `overload`. Adapter metadata keys are dropped; +/// non-identifier keys are a hard error (they become env names/filenames). +/// Undecryptable values abort under `strict`; otherwise they are skipped +/// and recorded in [`Resolution::failed`]. +pub fn resolve_all( + files: &[PathBuf], + ring: &KeyRing, + overload: bool, + strict: bool, + env_has: &dyn Fn(&str) -> bool, + naming: KeyNaming, +) -> Result { + let metadata_prefix = naming.metadata_prefix(); + let mut order: Vec = Vec::new(); + let mut merged: HashMap)> = HashMap::new(); + for f in files { + // O_NOFOLLOW on the ciphertext open (audit 2026-08-02 A13): + // refuse to read through a planted symlink. + let fd = rustix::fs::open( + f, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|e| format!("{}: {e} (refusing to follow a symlink)", f.display()))?; + let iter = dotenvy::from_read_iter(std::fs::File::from(fd)); + let mut file_vals: HashMap> = HashMap::new(); + let mut file_order: Vec = Vec::new(); + for item in iter { + let (k, v) = item.map_err(|e| format!("{}: {e}", f.display()))?; + let v = Zeroizing::new(v); + if let Some(prefix) = metadata_prefix + && k.starts_with(prefix) + { + continue; + } + if !is_ident(&k) { + return Err(format!( + "{}: refusing non-identifier key {k:?}", + f.display() + )); + } + if !file_vals.contains_key(&k) { + file_order.push(k.clone()); + } + file_vals.insert(k, v); // last assignment within a file wins + } + for k in file_order { + // Unreachable invariant (file_order mirrors file_vals), but a + // clear error beats an abort under panic=abort (audit + // 2026-08-02 A22). + let Some(v) = file_vals.remove(&k) else { + return Err(format!("internal: {k} in file_order but not file_vals")); + }; + if let Some(slot) = merged.get_mut(&k) { + if overload { + *slot = (f.clone(), v); // later file wins under overload + } + } else { + order.push(k.clone()); + merged.insert(k, (f.clone(), v)); + } + } + } + let mut out = Vec::new(); + let mut failed = Vec::new(); + for k in order { + if !overload && env_has(&k) { + continue; // process environment wins + } + // Unreachable invariant (order mirrors merged), but a clear error + // beats an abort under panic=abort (audit 2026-08-02 A22). + let Some((file, raw)) = merged.remove(&k) else { + return Err(format!("internal: {k} in order but not merged")); + }; + match decrypt_value(&file, &k, &raw, ring, naming) { + Ok(v) => out.push((k, v)), + Err(e) if strict => return Err(e), + Err(e) => { + crate::server::log(&format!("non-strict: skipping {k}: {e}")); + failed.push((k, e)); + } + } + } + Ok(Resolution { + resolved: out, + failed, + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, unused_qualifications)] +/// Inline unit tests for key loading, resolution, and decryption. +pub mod tests { + use super::*; + use crate::adapter::KeyNaming; + + /// Test secret key hex — never use outside tests. + pub const TEST_SK_HEX: &str = + "1111111111111111111111111111111111111111111111111111111111111111"; + + /// Build a test key ring with the test secret key under + /// `POSTMASTER_KEY` (for bundle adapter tests). + pub fn make_test_ring() -> KeyRing { + let pairs = vec![( + "POSTMASTER_KEY".to_string(), + Zeroizing::new(TEST_SK_HEX.to_string()), + )]; + KeyRing::from_pairs(pairs, &[KeyNaming::Bundle]).unwrap() + } + use std::io::Write; + use std::path::Path; + + /// Shared with the `load` submodule's tests. + pub fn with_temp_keys(content: &str, f: impl FnOnce(&Path)) { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "postmaster-test-keys-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + { + let mut f = std::fs::File::create(&path).unwrap(); + writeln!(f, "{}", content).unwrap(); + } + // KeyRing::load now enforces 0600-or-stricter permissions (audit + // finding #1). The temp file must pass that check. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); + f(&path); + let _ = std::fs::remove_file(&path); + } + + /// Shared with the `load` submodule's tests. + pub fn tdir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("pm-keys-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn key_var_for_basic() { + assert_eq!( + KeyNaming::Env + .key_var_for(std::path::Path::new(".env")) + .unwrap(), + "DOTENV_PRIVATE_KEY" + ); + assert_eq!( + KeyNaming::Env + .key_var_for(std::path::Path::new(".env.production")) + .unwrap(), + "DOTENV_PRIVATE_KEY_PRODUCTION" + ); + assert_eq!( + KeyNaming::Env + .key_var_for(std::path::Path::new(".env.staging_v2")) + .unwrap(), + "DOTENV_PRIVATE_KEY_STAGING_V2" + ); + } + + #[test] + fn load_rotated_keys_and_plaintext_env() { + // Create a real .env file with a plaintext value (simulating --plain) + let env_dir = std::env::temp_dir(); + let env_path = env_dir.join(format!( + "postmaster-test-env-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + { + let mut f = std::fs::File::create(&env_path).unwrap(); + writeln!(f, "PLAIN=hello-world").unwrap(); + } + + let entry = Entry { + env_file: env_path.clone(), + key: "PLAIN".to_string(), + peer_user: None, + }; + // Ring can be empty for plaintext path + let ring = KeyRing(HashMap::new()); + let result = resolve(&entry, &ring, KeyNaming::Env).unwrap(); + assert_eq!(&*result, b"hello-world"); + + let _ = std::fs::remove_file(&env_path); + } + + #[test] + fn from_pairs_filters_and_rotates() { + let ring = KeyRing::from_pairs( + [ + ("UNRELATED".to_string(), Zeroizing::new("x".to_string())), + ( + "DOTENV_PRIVATE_KEY_PRODUCTION".to_string(), + Zeroizing::new(format!("{},{}", "22".repeat(32), "33".repeat(32))), + ), + ], + &[KeyNaming::Env], + ) + .unwrap(); + assert_eq!(ring.0.len(), 1); + assert_eq!(ring.0["DOTENV_PRIVATE_KEY_PRODUCTION"].len(), 2); + } + + #[test] + fn from_pairs_admits_exactly_the_required_namings() { + // The bundle-ring gap fix: a config requiring both adapters must + // get both prefix families in the ring, and nothing else. + let pairs = || { + [ + ( + "DOTENV_PRIVATE_KEY".to_string(), + Zeroizing::new("22".repeat(32)), + ), + ( + "POSTMASTER_KEY".to_string(), + Zeroizing::new("33".repeat(32)), + ), + ("UNRELATED".to_string(), Zeroizing::new("44".repeat(32))), + ] + }; + + let both = KeyRing::from_pairs(pairs(), &[KeyNaming::Env, KeyNaming::Bundle]).unwrap(); + assert!(both.candidates("DOTENV_PRIVATE_KEY").is_some()); + assert!(both.candidates("POSTMASTER_KEY").is_some()); + assert_eq!(both.0.len(), 2, "UNRELATED must be dropped"); + + // Least privilege: a naming the config does not require is NOT + // admitted, even when the source provides it. + let env_only = KeyRing::from_pairs(pairs(), &[KeyNaming::Env]).unwrap(); + assert!(env_only.candidates("DOTENV_PRIVATE_KEY").is_some()); + assert!(env_only.candidates("POSTMASTER_KEY").is_none()); + + // A bundle-only requirement with only Env material present fails + // closed with the adapter's own empty message. + let err = KeyRing::from_pairs( + [( + "DOTENV_PRIVATE_KEY".to_string(), + Zeroizing::new("22".repeat(32)), + )], + &[KeyNaming::Bundle], + ) + .unwrap_err(); + assert_eq!(err, KeyNaming::Bundle.empty_error()); + } + + #[test] + fn from_pairs_empty_is_error() { + assert!( + KeyRing::from_pairs( + [("A".to_string(), Zeroizing::new("b".to_string()))], + &[KeyNaming::Env] + ) + .is_err() + ); + } + + #[test] + fn is_ident_rules() { + assert!(is_ident("DATABASE_URL") && is_ident("_x9")); + assert!(!is_ident("9x") && !is_ident("a-b") && !is_ident("") && !is_ident("a b")); + } + + #[test] + fn resolve_all_precedence() { + let d = tdir("prec"); + std::fs::write( + d.join(".env"), + "A=first\nB=one\nB=two\nDOTENV_PUBLIC_KEY=zz\n", + ) + .unwrap(); + std::fs::write(d.join(".env.local"), "A=second\nC=three\n").unwrap(); + let files = [d.join(".env"), d.join(".env.local")]; + let ring = KeyRing(HashMap::new()); + + // default: first file wins; within a file last assignment wins; + // process-env presence suppresses; DOTENV_* filtered. + let out = resolve_all(&files, &ring, false, true, &|k| k == "C", KeyNaming::Env).unwrap(); + let got: Vec<(&str, &[u8])> = out + .resolved + .iter() + .map(|(k, v)| (k.as_str(), &v[..])) + .collect(); + assert_eq!( + got, + vec![("A", b"first".as_slice()), ("B", b"two".as_slice())] + ); + + // overload: later file wins, env presence ignored. + let out = resolve_all(&files, &ring, true, true, &|k| k == "C", KeyNaming::Env).unwrap(); + let got: Vec<(&str, &[u8])> = out + .resolved + .iter() + .map(|(k, v)| (k.as_str(), &v[..])) + .collect(); + assert_eq!( + got, + vec![ + ("A", b"second".as_slice()), + ("B", b"two".as_slice()), + ("C", b"three".as_slice()) + ] + ); + } + + #[test] + fn resolve_all_rejects_non_identifier() { + // dotenvy rejects keys starting with `-` or digit during parsing, but accepts `.` + // in subsequent characters (e.g., BAD.KEY=x parses fine). Our is_ident check rejects + // `.` in all positions, so a dotted key exercises the rejection branch in resolve_all. + let d = tdir("ident"); + std::fs::write(d.join(".env"), "BAD.KEY=x\n").unwrap(); + let ring = KeyRing(HashMap::new()); + let e = resolve_all( + &[d.join(".env")], + &ring, + false, + true, + &|_| false, + KeyNaming::Env, + ) + .unwrap_err(); + assert!(e.contains("non-identifier"), "{e}"); + } + + #[test] + fn resolve_all_strict_fails_on_undecryptable() { + let d = tdir("strict"); + std::fs::write(d.join(".env"), "A=encrypted:!!!notbase64\n").unwrap(); + let ring = KeyRing(HashMap::new()); + assert!( + resolve_all( + &[d.join(".env")], + &ring, + false, + true, + &|_| false, + KeyNaming::Env + ) + .is_err() + ); + // non-strict: skipped, not served wrong — and the skip is + // recorded so --verify-keys can fail closed on it (audit + // 2026-08-02 A3). + let out = resolve_all( + &[d.join(".env")], + &ring, + false, + false, + &|_| false, + KeyNaming::Env, + ) + .unwrap(); + assert!(out.resolved.is_empty()); + assert_eq!(out.failed.len(), 1); + assert_eq!(out.failed[0].0, "A"); + } +} diff --git a/src/keys/provisioning/cleanup.rs b/src/keys/provisioning/cleanup.rs new file mode 100644 index 0000000..4dc7bde --- /dev/null +++ b/src/keys/provisioning/cleanup.rs @@ -0,0 +1,288 @@ +//! `postmaster cleanup` command path. + +use std::os::fd::AsFd; +use std::path::Path; + +use crate::secret_files::{MANAGED_SENTINEL, open_secrets_dir, read_dir_names}; +use crate::server::log; + +/// `postmaster cleanup`: remove all secret files from the secrets_dir +/// configured in the exec config. Intended for `ExecStopPost` in systemd +/// or equivalent lifecycle hooks. Does not decrypt — only removes files +/// that postmaster would have written. Bulk removal is guarded by the +/// `.postmaster-managed` sentinel: a non-empty directory lacking it is +/// refused (postmaster only bulk-deletes directories it manages). The +/// sentinel is removed last, and ONLY when every managed regular file +/// was removed successfully — on any stat/unlink failure the sentinel +/// is RETAINED and the command fails, so a retry is not refused as +/// unmanaged while plaintext files survive. Safe to run even if the +/// directory is empty or does not exist. +pub fn cleanup(config_path: &Path) -> Result<(), String> { + let cfg = crate::launcher::parse_exec_config(config_path)?; + let dir = cfg + .secrets_dir + .as_deref() + .ok_or("cleanup: config has no secrets_dir — nothing to clean up")?; + // Pin the directory by fd: O_DIRECTORY|O_NOFOLLOW rejects a symlinked + // or non-directory secrets dir at open, and the sentinel check, the + // per-entry stat, and every unlink below are all relative to the + // pinned inode — no path is ever re-resolved (audit 2026-08-02 + // A12/A19). + let dir_fd = match open_secrets_dir(dir) { + Ok(fd) => fd, + Err(rustix::io::Errno::NOENT) => { + log(&format!( + "cleanup: {} does not exist; nothing to clean up", + dir.display() + )); + return Ok(()); + } + Err(e @ (rustix::io::Errno::LOOP | rustix::io::Errno::NOTDIR)) => { + return Err(format!( + "{}: cleanup: refusing a symlinked or non-directory secrets dir ({e})", + dir.display() + )); + } + Err(e) => return Err(format!("{}: cleanup: {e}", dir.display())), + }; + let names = read_dir_names(&dir_fd).map_err(|e| format!("{}: cleanup: {e}", dir.display()))?; + if names.is_empty() { + log(&format!( + "cleanup: {} is empty; nothing to clean up", + dir.display() + )); + return Ok(()); + } + // Managed-dir guard (audit 2026-08-02 A19): non-empty and no + // sentinel means postmaster never wrote here — bulk deletes could + // hit foreign files. Refuse. + match rustix::fs::statat( + &dir_fd, + MANAGED_SENTINEL, + rustix::fs::AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(stat) if rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file() => {} + _ => { + return Err(format!( + "{}: cleanup: no {MANAGED_SENTINEL} sentinel; refusing to bulk-delete in a directory postmaster did not write to", + dir.display() + )); + } + } + let mut removed = 0_usize; + let mut had_failure = false; + for name in &names { + // The sentinel is infrastructure, not a secret — skipped here + // and removed last, so an interrupted cleanup can be re-run. + if name.to_str() == Some(MANAGED_SENTINEL) { + continue; + } + let path = dir.join(name); + match rustix::fs::statat(&dir_fd, name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW) { + Ok(stat) if rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file() => { + match rustix::fs::unlinkat(&dir_fd, name, rustix::fs::AtFlags::empty()) { + Ok(()) => removed += 1, + Err(e) => { + had_failure = true; + log(&format!("{}: cleanup: {e}", path.display())); + } + } + } + Ok(stat) => { + // Subdirectories and symlinks are skipped, not failures. + log(&format!( + "{}: cleanup: skipping non-file (type {:?})", + path.display(), + rustix::fs::FileType::from_raw_mode(stat.st_mode) + )); + } + Err(e) => { + had_failure = true; + log(&format!("{}: cleanup: stat: {e}", path.display())); + } + } + } + finish_cleanup(&dir_fd, dir, removed, had_failure) +} + +/// Sentinel disposition after the removal pass. The sentinel is removed +/// last (so an interrupted cleanup can be re-run), and ONLY when every +/// managed regular file came out: on any stat/unlink failure it is +/// retained and the command fails — removing it would make the next +/// retry refuse the directory as unmanaged and strand the surviving +/// plaintext files. +fn finish_cleanup( + dir_fd: impl AsFd, + dir: &Path, + removed: usize, + had_failure: bool, +) -> Result<(), String> { + if had_failure { + return Err(format!( + "{}: cleanup: not all files could be removed; retaining the {MANAGED_SENTINEL} sentinel so a retry is not refused as unmanaged", + dir.display() + )); + } + // Remove the sentinel last (see above); best-effort — with every + // secret gone, a surviving sentinel is harmless. + match rustix::fs::unlinkat(dir_fd, MANAGED_SENTINEL, rustix::fs::AtFlags::empty()) { + Ok(()) | Err(rustix::io::Errno::NOENT) => {} + Err(e) => log(&format!( + "{}: cleanup: {e}", + dir.join(MANAGED_SENTINEL).display() + )), + } + log(&format!( + "cleanup: removed {removed} file(s) from {}", + dir.display() + )); + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn cleanup_removes_all_files_from_secrets_dir() { + let dir = std::env::temp_dir().join(format!("pm-keyprov-cleanup-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + // cleanup requires a postmaster-managed (owner-only) dir + // (audit 2026-08-02 A19); match real deployments. + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(dir.join("KEY_A"), b"secret-a").unwrap(); + fs::write(dir.join("KEY_B"), b"secret-b").unwrap(); + fs::create_dir(dir.join("subdir")).unwrap(); + fs::write(dir.join(MANAGED_SENTINEL), b"").unwrap(); + + let cfg_path = dir.join("exec.json"); + fs::write( + &cfg_path, + format!( + r#"{{"mode":"files","keys":["environment"],"env_files":["/nonexistent"],"secrets_dir":"{}"}}"#, + dir.display() + ), + ) + .unwrap(); + + cleanup(&cfg_path).unwrap(); + + assert!(!dir.join("KEY_A").exists(), "KEY_A should be removed"); + assert!(!dir.join("KEY_B").exists(), "KEY_B should be removed"); + assert!(dir.join("subdir").exists(), "subdir should be preserved"); + assert!( + !dir.join(MANAGED_SENTINEL).exists(), + "sentinel is removed last" + ); + } + + #[test] + fn cleanup_refuses_unmanaged_dir() { + // audit 2026-08-02 A19: a non-empty dir without the sentinel was + // never written by postmaster; cleanup must refuse to bulk-delete + // there (foreign files could be present). + let dir = std::env::temp_dir().join(format!("pm-keyprov-unmanaged-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("FOREIGN"), b"not-ours").unwrap(); + let cfg_path = dir.join("exec.json"); + fs::write( + &cfg_path, + format!( + r#"{{"mode":"files","keys":["environment"],"env_files":["/nonexistent"],"secrets_dir":"{}"}}"#, + dir.display() + ), + ) + .unwrap(); + + let err = cleanup(&cfg_path).unwrap_err(); + + assert!(err.contains("sentinel"), "{err}"); + assert!(dir.join("FOREIGN").exists(), "nothing may be deleted"); + } + + #[test] + fn cleanup_nonexistent_dir_is_ok() { + let dir = std::env::temp_dir().join(format!("pm-keyprov-missing-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let cfg_path = dir.join("exec.json"); + fs::write( + &cfg_path, + format!( + r#"{{"mode":"files","keys":["environment"],"env_files":["/nonexistent"],"secrets_dir":"{}/no-such-dir"}}"#, + dir.display() + ), + ) + .unwrap(); + + assert!(cleanup(&cfg_path).is_ok()); + } + + #[test] + fn cleanup_retains_sentinel_after_a_failed_removal() { + // Root bypasses directory permission checks (CAP_DAC_OVERRIDE), + // so the induced unlink failure below only occurs for non-root. + if rustix::process::geteuid().is_root() { + return; + } + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("pm-keyprov-partial-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(dir.join("KEY_A"), b"secret-a").unwrap(); + fs::write(dir.join(MANAGED_SENTINEL), b"").unwrap(); + let cfg_path = dir.join("exec.json"); + fs::write( + &cfg_path, + format!( + r#"{{"mode":"files","keys":["environment"],"env_files":["/nonexistent"],"secrets_dir":"{}"}}"#, + dir.display() + ), + ) + .unwrap(); + // Make every unlink inside the dir fail (EACCES): removal needs + // write permission on the containing directory. Enumeration and + // stat still work (r-x), so KEY_A is found and its removal fails. + fs::set_permissions(&dir, fs::Permissions::from_mode(0o500)).unwrap(); + + let err = cleanup(&cfg_path).unwrap_err(); + + // Restore write permission so the temp dir can be cleaned up. + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + assert!(err.contains("retaining"), "{err}"); + assert!( + dir.join(MANAGED_SENTINEL).exists(), + "the sentinel must be retained so a retry is not refused as unmanaged" + ); + assert!( + dir.join("KEY_A").exists(), + "the file whose removal failed must still be there" + ); + } + + #[test] + fn finish_cleanup_removes_sentinel_only_without_failures() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("pm-keyprov-finish-{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(dir.join(MANAGED_SENTINEL), b"").unwrap(); + let dir_fd = open_secrets_dir(&dir).unwrap(); + + // Failure leg: the sentinel is retained and the command fails. + let err = finish_cleanup(&dir_fd, &dir, 0, true).unwrap_err(); + assert!(err.contains("retaining"), "{err}"); + assert!(dir.join(MANAGED_SENTINEL).exists()); + + // Clean leg: the sentinel goes and the command succeeds. + finish_cleanup(&dir_fd, &dir, 1, false).unwrap(); + assert!(!dir.join(MANAGED_SENTINEL).exists()); + } +} diff --git a/src/keys/provisioning/mod.rs b/src/keys/provisioning/mod.rs new file mode 100644 index 0000000..de31284 --- /dev/null +++ b/src/keys/provisioning/mod.rs @@ -0,0 +1,549 @@ +//! Key provisioning — setup and verification of key sources, key +//! directory creation, keychain seeding, and runtime key source checks. +//! +//! This module implements the `postmaster setup` and `postmaster cleanup` +//! commands, plus the key-provisioning helpers they depend on. + +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use crate::exec_config::{ExecConfig, KeySourceSpec}; +use crate::server::log; + +mod cleanup; +mod setup; + +/// The `postmaster cleanup` command, re-exported so +/// `crate::key_provisioning::cleanup` keeps resolving. +pub use cleanup::cleanup; +/// The `postmaster setup` command, re-exported so +/// `crate::key_provisioning::setup` keeps resolving. +pub use setup::setup; + +/// Default keys directory path: `/var/lib/postmaster` on Linux, +/// `~/Library/Application Support/postmaster` on macOS. +fn default_keys_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + if let Some(home) = std::env::var_os("HOME") { + return PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("postmaster"); + } + } + PathBuf::from("/var/lib/postmaster") +} + +/// Create the keys directory with 0700 permissions if it doesn't exist. +/// If it exists, verify permissions and ownership are correct. Idempotent +/// — skips if already correct unless `force` is true. +#[allow(clippy::print_stderr)] +fn ensure_keys_dir(keys_dir: &Path, _force: bool) -> Result<(), String> { + if keys_dir.exists() { + // Open the directory itself and work on the fd throughout: + // O_DIRECTORY|O_NOFOLLOW rejects symlinks and non-directories at + // open, and fchmod on the fd eliminates the TOCTOU between a + // path-based check and a path-based chmod (audit 2026-08-02 A5). + let dir_fd = rustix::fs::open( + keys_dir, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|e| { + format!( + "{}: cannot open keys directory: {e} (refusing a symlink or non-directory)", + keys_dir.display() + ) + })?; + let stat = rustix::fs::fstat(&dir_fd) + .map_err(|e| format!("{}: fstat: {e}", keys_dir.display()))?; + let mode = rustix::fs::Mode::from_bits_truncate(stat.st_mode); + if mode.intersects(rustix::fs::Mode::RWXG | rustix::fs::Mode::RWXO) { + // Fix permissions if we can + rustix::fs::fchmod(&dir_fd, rustix::fs::Mode::RWXU) + .map_err(|e| format!("{}: cannot fix permissions: {e}", keys_dir.display()))?; + log(&format!( + "{}: permissions corrected to 0700", + keys_dir.display() + )); + } + log(&format!("{}: keys directory verified", keys_dir.display())); + return Ok(()); + } + + // Create the directory with 0700. create_dir_all follows intermediate + // symlinks, so first reject any existing symlink component in the + // prefix (audit 2026-08-02 O5). Residual TOCTOU: a component could be + // swapped between this check and the create, but an attacker with + // that write access could plant the symlink outright — and the open + // below is still NOFOLLOW on the final component. + let mut prefix = PathBuf::new(); + for comp in keys_dir.components() { + prefix.push(comp); + if let Ok(md) = fs::symlink_metadata(&prefix) + && md.file_type().is_symlink() + { + return Err(format!( + "{}: refusing to create keys directory through a symlinked component", + prefix.display() + )); + } + } + fs::create_dir_all(keys_dir) + .map_err(|e| format!("{}: cannot create keys directory: {e}", keys_dir.display()))?; + let dir_fd = rustix::fs::open( + keys_dir, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|e| format!("{}: cannot open keys directory: {e}", keys_dir.display()))?; + rustix::fs::fchmod(&dir_fd, rustix::fs::Mode::RWXU) + .map_err(|e| format!("{}: cannot set permissions: {e}", keys_dir.display()))?; + log(&format!( + "{}: keys directory created with 0700", + keys_dir.display() + )); + Ok(()) +} + +/// Verify a keys file exists with correct permissions (0600, owned by +/// euid). If the file doesn't exist, in interactive mode, prompt the +/// operator to create it. In unattended mode, fail closed. If the file +/// exists but has wrong permissions, fix them. +#[allow(clippy::print_stderr)] +fn ensure_keys_file(path: &Path, _force: bool, unattended: bool) -> Result<(), String> { + if path.exists() { + // Verify permissions and ownership (same checks as setup_recheck) + let oflags = + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC; + let fd = rustix::fs::open(path, oflags, rustix::fs::Mode::empty()) + .map_err(|e| format!("{}: {e} (refusing to follow a symlink)", path.display()))?; + let stat = rustix::fs::fstat(&fd).map_err(|e| format!("{}: fstat: {e}", path.display()))?; + let mode = rustix::fs::Mode::from_bits_truncate(stat.st_mode); + if mode.intersects(rustix::fs::Mode::RWXG | rustix::fs::Mode::RWXO) { + // Fix permissions + rustix::fs::fchmod(&fd, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR) + .map_err(|e| format!("{}: cannot fix permissions: {e}", path.display()))?; + log(&format!( + "{}: permissions corrected to 0600", + path.display() + )); + } + let euid = rustix::process::geteuid().as_raw(); + if stat.st_uid != euid { + return Err(format!( + "{}: owned by uid {} but postmaster euid is {euid} (cannot fix ownership automatically)", + path.display(), + stat.st_uid + )); + } + log(&format!("{}: keys file verified", path.display())); + return Ok(()); + } + + // File doesn't exist + if unattended { + return Err(format!( + "{}: keys file not found (unattended mode — cannot prompt; create the file manually before running setup)", + path.display() + )); + } + + // Interactive: prompt the operator + eprintln!("{}: keys file not found.", path.display()); + eprintln!( + "Create it now? You will need to paste the key contents (DOTENV_PRIVATE_KEY=<64-hex>...). [y/N] " + ); + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + return Err(format!("{}: cannot read input", path.display())); + } + if !input.trim().eq_ignore_ascii_case("y") { + return Err(format!( + "{}: operator declined to create keys file", + path.display() + )); + } + + eprintln!("Paste the keys file contents (Ctrl+D to finish):"); + // Zeroizing: operator-pasted key material must not linger in freed + // heap pages (audit 2026-08-02 O4). + let mut contents = zeroize::Zeroizing::new(String::new()); + if std::io::stdin().read_to_string(&mut contents).is_err() { + return Err(format!("{}: cannot read key contents", path.display())); + } + + // Write the file with 0600. O_EXCL | O_NOFOLLOW makes creation + // fail-closed: it refuses to overwrite anything already at the path + // and refuses to follow a planted symlink. + let oflags = rustix::fs::OFlags::WRONLY + | rustix::fs::OFlags::CREATE + | rustix::fs::OFlags::EXCL + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC; + let fd = rustix::fs::open(path, oflags, rustix::fs::Mode::from_bits_truncate(0o600)).map_err( + |e| { + format!( + "{}: cannot create: {e} (refusing to overwrite or follow a symlink)", + path.display() + ) + }, + )?; + let mut file = fs::File::from(fd); + use std::io::Write; + file.write_all(contents.as_bytes()) + .map_err(|e| format!("{}: cannot write: {e}", path.display()))?; + log(&format!("{}: keys file created with 0600", path.display())); + Ok(()) +} + +/// Verify keychain items exist for the wanted key variables. If they +/// don't exist and we're in interactive mode, offer to seed them. +/// On macOS only. +#[cfg(target_os = "macos")] +#[allow(clippy::print_stderr)] +fn ensure_keychain_items( + service: &str, + wanted: &[String], + force: bool, + unattended: bool, +) -> Result<(), String> { + let mut missing: Vec<&str> = Vec::new(); + for var in wanted { + // Existence check via the Security.framework API (F1) — no + // subprocess; retrieved bytes zeroized on drop (A17). + match security_framework::passwords::get_generic_password(service, var) { + Ok(bytes) => { + let _ = zeroize::Zeroizing::new(bytes); // zeroize on drop + } + Err(_) => missing.push(var), + } + } + + if missing.is_empty() && !force { + log(&format!( + "keychain {service}: all {wanted_len} item(s) verified", + wanted_len = wanted.len() + )); + return Ok(()); + } + + if unattended && !missing.is_empty() { + return Err(format!( + "keychain {service}: {missing_len} item(s) missing (unattended mode — cannot prompt)", + missing_len = missing.len() + )); + } + + // Interactive: offer to seed missing items + if !missing.is_empty() { + eprintln!( + "keychain {service}: missing items for: {}", + missing.join(", ") + ); + eprintln!("Seed them now? You will need to paste each key value. [y/N] "); + let mut input = String::new(); + if std::io::stdin().read_line(&mut input).is_err() { + return Err("cannot read input".into()); + } + if !input.trim().eq_ignore_ascii_case("y") { + return Err(format!( + "keychain {service}: operator declined to seed missing items" + )); + } + + for var in &missing { + eprint!("Paste value for {var}: "); + // Zeroizing: pasted key material must not linger in freed + // heap pages (audit 2026-08-02 O4). `trim()` borrows, no copy. + let mut value = zeroize::Zeroizing::new(String::new()); + if std::io::stdin().read_line(&mut value).is_err() { + return Err(format!("cannot read value for {var}")); + } + let value = value.trim(); + if value.is_empty() { + return Err(format!("{var}: empty value, refusing to seed")); + } + // Seed via the Security.framework API, not the security CLI: + // `add-generic-password -w ` exposes key material in + // the subprocess argv, readable by any local process via ps / + // proc_pidinfo (audit 2026-08-02 O3; F1 crate direction). + security_framework::passwords::set_generic_password(service, var, value.as_bytes()) + .map_err(|e| format!("keychain {service}: failed to seed {var}: {e}"))?; + log(&format!("keychain {service}: seeded {var}")); + } + } + + Ok(()) +} + +/// Verify that all configured key sources are accessible. This is the +/// `--recheck` path — same as the old `setup_recheck` function. +fn verify_key_sources(cfg: &ExecConfig, wanted: &[String]) -> Result<(), String> { + // `wanted` is only consulted by the macOS keychain check below. + #[cfg(not(target_os = "macos"))] + let _ = wanted; + let mut checked = 0_u32; + let mut found = 0_u32; + + for spec in &cfg.keys { + match spec { + KeySourceSpec::Credential(name) => { + checked += 1; + let dir = std::env::var_os("CREDENTIALS_DIRECTORY") + .ok_or("$CREDENTIALS_DIRECTORY is not set")?; + let p = Path::new(&dir).join(name); + if !p.exists() { + return Err(format!( + "{}: credential source not found under $CREDENTIALS_DIRECTORY", + p.display() + )); + } + found += 1; + } + KeySourceSpec::File(path) => { + checked += 1; + if path.starts_with("/nix/store") { + return Err(format!( + "{}: refusing plaintext private keys from the world-readable Nix store", + path.display() + )); + } + let oflags = rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC; + let fd = + rustix::fs::open(path, oflags, rustix::fs::Mode::empty()).map_err(|e| { + format!("{}: {e} (refusing to follow a symlink)", path.display()) + })?; + let stat = rustix::fs::fstat(&fd) + .map_err(|e| format!("{}: fstat: {e}", path.display()))?; + let mode = rustix::fs::Mode::from_bits_truncate(stat.st_mode); + if mode.intersects(rustix::fs::Mode::RWXG | rustix::fs::Mode::RWXO) { + return Err(format!( + "{}: permissions {:#o} are group/world accessible; expected 0400 or 0600", + path.display(), + stat.st_mode + )); + } + let euid = rustix::process::geteuid().as_raw(); + if stat.st_uid != euid { + return Err(format!( + "{}: owned by uid {} but postmaster euid is {euid}", + path.display(), + stat.st_uid + )); + } + found += 1; + } + KeySourceSpec::Keychain(service) => { + #[cfg(target_os = "macos")] + { + checked += 1; + for var in wanted { + // Security.framework API (F1) — no subprocess; + // retrieved bytes zeroized on drop (A17). + match security_framework::passwords::get_generic_password(service, var) { + Ok(bytes) => { + let _ = zeroize::Zeroizing::new(bytes); // zeroize on drop + } + Err(_) => { + return Err(format!( + "keychain {service}: no item for account {var} (fail closed)" + )); + } + } + } + found += 1; + } + #[cfg(not(target_os = "macos"))] + { + let _ = service; + return Err("keychain source is only available on macOS".into()); + } + } + KeySourceSpec::Environment => { + checked += 1; + // Every naming the config's artifacts require must be + // present: accepting ANY single prefix let `POSTMASTER_KEY` + // satisfy verification for an env-files-only config (setup + // said OK, exec then failed) — and vice versa. + // + // The SAME predicate KeyRing::from_env applies (audit + // 2026-08-02 A16): a bare starts_with would count a + // prefix-substring collision like DOTENV_PRIVATE_KEYFOO + // as key material that from_env then refuses to load — + // verification must not pass what resolution rejects. + for naming in cfg.required_namings() { + let prefix = naming.key_prefix(); + if !std::env::vars().any(|(k, _)| crate::keys::is_key_var_name(prefix, &k)) { + return Err(format!("environment: no {prefix}* variables set")); + } + } + found += 1; + } + } + } + + if checked == 0 { + return Err("no key sources configured (configure keys=[…])".into()); + } + + log(&format!( + "setup: {found}/{checked} key source(s) verified — environment ready" + )); + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use crate::adapter::KeyNaming; + use crate::test_util::ENV_LOCK; + + fn env_only_cfg() -> ExecConfig { + serde_json::from_str(r#"{"mode":"env","keys":["environment"],"env_files":["/x/.env"]}"#) + .unwrap() + } + + #[test] + fn verify_key_sources_uses_from_env_predicate() { + // audit 2026-08-02 A16: verification must admit exactly what + // KeyRing::from_env admits. A prefix-substring collision like + // DOTENV_PRIVATE_KEYFOO is NOT a key var, so with only such a + // var set, verification must fail. + let _guard = ENV_LOCK.lock().unwrap(); + let cfg = env_only_cfg(); + let bogus = "DOTENV_PRIVATE_KEYFOO_PMVERIFY"; + let valid = "DOTENV_PRIVATE_KEY_PMVERIFY"; + + // Positive leg: an exact `PREFIX_` var passes. Robust + // against any valid key vars ambient in the test runner's own + // environment. + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + unsafe { + std::env::set_var(valid, "aa".repeat(32)); + } + assert!( + verify_key_sources(&cfg, &[]).is_ok(), + "an exact PREFIX_ variable must pass verification" + ); + + // Negative leg: only a prefix-substring collision remains. + unsafe { + std::env::remove_var(valid); + std::env::set_var(bogus, "aa".repeat(32)); + } + let res = verify_key_sources(&cfg, &[]); + unsafe { + std::env::remove_var(bogus); + } + // Guard against an ambient valid key var in the test runner's + // own environment (e.g. DOTENV_PRIVATE_KEY exported in CI): if + // one is present, the negative leg is not observable — skip + // rather than flake. A concurrent test cannot be the source: + // every env-mutating test holds the shared ENV_LOCK. Only the + // Env prefix counts here: this config requires Env naming alone. + let any_valid = std::env::vars() + .any(|(k, _)| crate::keys::is_key_var_name(KeyNaming::Env.key_prefix(), &k)); + if !any_valid { + let err = res.expect_err("a prefix-substring variable must fail verification"); + assert!(err.contains("environment:"), "{err}"); + } + } + + fn bundle_only_cfg() -> ExecConfig { + serde_json::from_str( + r#"{"mode":"env","keys":["environment"],"bundle_files":["/x/s.json"]}"#, + ) + .unwrap() + } + + fn mixed_cfg() -> ExecConfig { + serde_json::from_str( + r#"{"mode":"env","keys":["environment"],"env_files":["/x/.env"],"bundle_files":["/x/s.json"]}"#, + ) + .unwrap() + } + + /// True when a valid key var for `prefix` exists that this test did + /// NOT set itself (i.e. ambient in the runner's own environment). + fn ambient_key_var(prefix: &str, mine: &[&str]) -> bool { + std::env::vars() + .any(|(k, _)| !mine.contains(&k.as_str()) && crate::keys::is_key_var_name(prefix, &k)) + } + + #[test] + fn verify_key_sources_requires_each_required_naming() { + // The compounding half of the bundle-ring gap: verification used + // to accept EITHER prefix for the environment source, so an + // env-files-only config passed with only POSTMASTER_KEY present + // (then failed at exec), and a bundle-only config failed despite + // its POSTMASTER_KEY being right there. Every required naming + // must now be present. + // + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + let dotenv = "DOTENV_PRIVATE_KEY_PMREQUIRED"; + let bundle = "POSTMASTER_KEY_PMREQUIRED"; + let key_hex = "aa".repeat(32); + unsafe { + std::env::remove_var(dotenv); + std::env::remove_var(bundle); + } + + // bundle-only config + its POSTMASTER_KEY present → passes. + unsafe { + std::env::set_var(bundle, &key_hex); + } + assert!( + verify_key_sources(&bundle_only_cfg(), &[]).is_ok(), + "a bundle config's own POSTMASTER_KEY must satisfy verification" + ); + + // env-only config + only bundle material → fails (the old lie). + let res = verify_key_sources(&env_only_cfg(), &[]); + if !ambient_key_var(KeyNaming::Env.key_prefix(), &[dotenv, bundle]) { + let err = res.expect_err("POSTMASTER_KEY must not satisfy an env-only config"); + assert!(err.contains("DOTENV_PRIVATE_KEY"), "{err}"); + } + + // mixed config with only ONE of the two required namings → fails. + unsafe { + std::env::remove_var(bundle); + std::env::set_var(dotenv, &key_hex); + } + let res = verify_key_sources(&mixed_cfg(), &[]); + if !ambient_key_var(KeyNaming::Bundle.key_prefix(), &[dotenv, bundle]) { + let err = res.expect_err("a mixed config requires POSTMASTER_KEY too"); + assert!(err.contains("POSTMASTER_KEY"), "{err}"); + } + + // mixed config with both required namings present → passes. + unsafe { + std::env::set_var(bundle, &key_hex); + } + assert!( + verify_key_sources(&mixed_cfg(), &[]).is_ok(), + "both required namings present must pass" + ); + + unsafe { + std::env::remove_var(dotenv); + std::env::remove_var(bundle); + } + } +} diff --git a/src/keys/provisioning/setup.rs b/src/keys/provisioning/setup.rs new file mode 100644 index 0000000..957a6f1 --- /dev/null +++ b/src/keys/provisioning/setup.rs @@ -0,0 +1,80 @@ +//! `postmaster setup` command path. + +use std::path::{Path, PathBuf}; + +#[cfg(target_os = "macos")] +use super::ensure_keychain_items; +use super::{default_keys_dir, ensure_keys_dir, ensure_keys_file, verify_key_sources}; +use crate::adapter::KeyNaming; +use crate::exec_config::KeySourceSpec; +use crate::server::log; + +/// `postmaster setup`: verify platform prerequisites, create keys +/// directory with correct permissions, optionally seed keychain items, +/// and validate the environment. Idempotent — skips steps that are +/// already correct. +/// +/// `--recheck`: re-run verification checks only, without creating or +/// modifying anything. +/// +/// `--unattended`: non-interactive mode for automation. Uses defaults +/// or env vars instead of prompting. Fail closed on any missing +/// prerequisite. +/// +/// `--force`: re-seed or re-create even if the target state is already +/// present. +/// +/// `--keys-dir `: override the keys directory path. +pub fn setup( + config_path: &Path, + recheck: bool, + unattended: bool, + force: bool, + keys_dir_override: Option, +) -> Result<(), String> { + let cfg = crate::launcher::parse_exec_config(config_path)?; + cfg.validate_shape()?; + + // Build the wanted key variable list from both env and bundle files + let mut wanted: Vec = Vec::new(); + for f in &cfg.env_files { + wanted.push(KeyNaming::Env.key_var_for(f)?); + } + for f in &cfg.bundle_files { + wanted.push(KeyNaming::Bundle.key_var_for(f)?); + } + + if recheck { + // --recheck: verification only, no creation or modification + return verify_key_sources(&cfg, &wanted); + } + + // Step 1: Create or verify keys directory + let keys_dir = keys_dir_override.unwrap_or_else(default_keys_dir); + ensure_keys_dir(&keys_dir, force)?; + + // Step 2: Verify or create key files for file-source keys + for spec in &cfg.keys { + if let KeySourceSpec::File(path) = spec { + ensure_keys_file(path, force, unattended)?; + } + } + + // Step 3: Verify or seed keychain items (macOS only) + #[cfg(target_os = "macos")] + for spec in &cfg.keys { + if let KeySourceSpec::Keychain(service) = spec { + ensure_keychain_items(service, &wanted, force, unattended)?; + } + } + + // Step 4: Verify the full environment (same as --recheck) + verify_key_sources(&cfg, &wanted)?; + + log(&format!( + "setup: complete — keys dir {}, {} key source(s) verified", + keys_dir.display(), + cfg.keys.len() + )); + Ok(()) +} diff --git a/src/keys/source.rs b/src/keys/source.rs new file mode 100644 index 0000000..e2dc16f --- /dev/null +++ b/src/keys/source.rs @@ -0,0 +1,308 @@ +//! Key source selection — loads the private key ring from the first +//! available source per the exec config's ordered `keys` list. +//! +//! `credential` is skippable (absent `$CREDENTIALS_DIRECTORY` or file ⇒ +//! try the next source); every other source is terminal: once selected, +//! its failure fails the launcher. + +use std::fs; +use std::path::Path; + +use crate::adapter::KeyNaming; +use crate::exec_config::KeySourceSpec; +use crate::keys::KeyRing; + +/// Ordered key-source selection. `credential` is skippable (no +/// $CREDENTIALS_DIRECTORY or file absent ⇒ try next source); every other +/// source is terminal: once selected, its failure fails the launcher. +/// +/// `namings` is the adapter set the config's artifacts require +/// (`ExecConfig::required_namings`): the ring admits exactly those key +/// prefixes. Hardcoding a single naming here previously dropped +/// `POSTMASTER_KEY*` at parse time and broke bundle configs end-to-end. +// `wanted_vars` is only consumed by the macOS keychain source; on other +// targets the parameter is unused, so silence that leg's clippy warning +// without touching the signature or source-selection semantics. +#[cfg_attr(not(target_os = "macos"), allow(unused_variables))] +pub fn load_ring( + specs: &[KeySourceSpec], + wanted_vars: &[String], + namings: &[KeyNaming], +) -> Result { + for spec in specs { + match spec { + KeySourceSpec::Credential(name) => { + if let Some(dir) = std::env::var_os("CREDENTIALS_DIRECTORY") { + let p = Path::new(&dir).join(name); + // Use symlink_metadata instead of p.exists(): exists() + // follows symlinks, so a planted symlink at + // $CREDENTIALS_DIRECTORY/ pointing to a + // world-readable file would be silently followed. + // Reject symlinks outright (audit finding #2); the + // subsequent KeyRing::load also opens with O_NOFOLLOW + // as defense-in-depth. + match fs::symlink_metadata(&p) { + Ok(md) if md.file_type().is_symlink() => { + return Err(format!( + "{}: credential source is a symlink; refusing to follow (remove the symlink)", + p.display() + )); + } + Ok(md) if md.is_file() => return KeyRing::load(&p, namings), + Ok(_) => { + return Err(format!( + "{}: credential source is not a regular file", + p.display() + )); + } + Err(_) => { /* absent: fall through to next source */ } + } + } + // absent credential: fall through to the next source + } + KeySourceSpec::File(p) => { + if p.starts_with("/nix/store") { + return Err(format!( + "{}: refusing plaintext private keys from the world-readable Nix store", + p.display() + )); + } + return KeyRing::load(p, namings); + } + KeySourceSpec::Keychain(service) => { + #[cfg(target_os = "macos")] + return KeyRing::from_keychain(service, wanted_vars, namings); + #[cfg(not(target_os = "macos"))] + return Err(format!( + "key source keychain({service}) is only available on macOS" + )); + } + KeySourceSpec::Environment => return KeyRing::from_env(namings), + } + } + Err("no usable key source (configure keys=[…] or set DOTENV_PRIVATE_KEY*)".into()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, unused_qualifications)] +mod tests { + use super::*; + use crate::exec_config::KeySourceSpec; + use crate::test_util::ENV_LOCK; + + #[test] + fn load_ring_credential_is_skippable() { + // credential source with no CREDENTIALS_DIRECTORY set should fall + // through to the next source. Here the next source is "environment", + // which is terminal and will fail closed (no DOTENV_PRIVATE_KEY* + // vars in the test runner). The error must be from the environment + // source, not from the credential source. + // + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::remove_var("CREDENTIALS_DIRECTORY"); + } + let specs = vec![ + KeySourceSpec::Credential("nonexistent".to_string()), + KeySourceSpec::Environment, + ]; + let err = load_ring(&specs, &[], &[KeyNaming::Env]).unwrap_err(); + // The error should come from the environment source (terminal), + // proving credential fell through rather than erroring. + assert!( + err.contains("environment:") || err.contains("no usable key source"), + "credential should be skippable; got: {err}" + ); + } + + #[test] + fn load_ring_credential_rejects_symlink() { + // Audit finding #2: a symlink planted at + // $CREDENTIALS_DIRECTORY/ must be rejected, not followed. + use std::os::unix::fs::PermissionsExt; + let d = std::env::temp_dir().join(format!("pm-keysource-symlink-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + let real = d.join("real-keys"); + fs::write(&real, format!("DOTENV_PRIVATE_KEY={}\n", "11".repeat(32))).unwrap(); + fs::set_permissions(&real, fs::Permissions::from_mode(0o600)).unwrap(); + let cred_dir = d.join("creds"); + fs::create_dir_all(&cred_dir).unwrap(); + let link = cred_dir.join("dotenv-private-key"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("CREDENTIALS_DIRECTORY", &cred_dir); + } + let specs = vec![KeySourceSpec::Credential("dotenv-private-key".to_string())]; + let err = load_ring(&specs, &[], &[KeyNaming::Env]).unwrap_err(); + unsafe { + std::env::remove_var("CREDENTIALS_DIRECTORY"); + } + assert!( + err.contains("symlink"), + "credential symlink should be rejected; got: {err}" + ); + } + + #[test] + fn load_ring_credential_rejects_non_file() { + // A directory as a credential source name should be rejected, + // not silently skipped or treated as a file. + let d = std::env::temp_dir().join(format!("pm-keysource-dir-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + let cred_dir = d.join("creds"); + fs::create_dir_all(&cred_dir).unwrap(); + let sub = cred_dir.join("subdir"); + fs::create_dir_all(&sub).unwrap(); + + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("CREDENTIALS_DIRECTORY", &cred_dir); + } + let specs = vec![KeySourceSpec::Credential("subdir".to_string())]; + let err = load_ring(&specs, &[], &[KeyNaming::Env]).unwrap_err(); + unsafe { + std::env::remove_var("CREDENTIALS_DIRECTORY"); + } + assert!( + err.contains("not a regular file"), + "credential directory should be rejected; got: {err}" + ); + } + + #[test] + fn load_ring_file_is_terminal() { + // file source with a nonexistent path should return an error + // immediately, NOT fall through to the next source. We verify this + // by putting a valid "environment" source after the bad file source: + // if file were skippable, load_ring would try environment and fail + // with "environment:" — instead it must fail with the file path. + let ghost_path = std::env::temp_dir() + .join(format!("pm-keysource-terminal-{}", std::process::id())) + .join("ghost.keys"); + let specs = vec![ + KeySourceSpec::File(ghost_path.clone()), + KeySourceSpec::Environment, + ]; + let err = load_ring(&specs, &[], &[KeyNaming::Env]).unwrap_err(); + assert!( + err.contains("ghost.keys"), + "file source should be terminal and error with the path; got: {err}" + ); + } + + #[test] + fn load_ring_file_rejects_nix_store() { + // file source under /nix/store must return immediately with an + // error, not fall through to a subsequent source. + let specs = vec![ + KeySourceSpec::File(std::path::PathBuf::from("/nix/store/abc/.env.keys")), + KeySourceSpec::Environment, + ]; + let err = load_ring(&specs, &[], &[KeyNaming::Env]).unwrap_err(); + assert!( + err.contains("Nix store"), + "file source under /nix/store should be terminal and reject; got: {err}" + ); + } + + #[test] + fn load_ring_environment_is_terminal() { + // environment source with no DOTENV_PRIVATE_KEY* vars should return + // an error immediately, not fall through. Since it's the last source, + // we verify the error is from from_env, not from the "no usable key + // source" fallback message. + // + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + let var_name = "DOTENV_PRIVATE_KEY_TESTTERMINAL"; + unsafe { + std::env::remove_var(var_name); + } + let specs = vec![ + KeySourceSpec::Credential("skipped".to_string()), + KeySourceSpec::Environment, + ]; + // Remove CREDENTIALS_DIRECTORY so credential is skippable. + unsafe { + std::env::remove_var("CREDENTIALS_DIRECTORY"); + } + let err = load_ring(&specs, &[], &[KeyNaming::Env]).unwrap_err(); + // The error must be from environment (terminal), not "no usable key + // source" (which would mean environment also fell through). + assert!( + !err.contains("no usable key source"), + "environment should be terminal, not fall through; got: {err}" + ); + } + + #[test] + fn load_ring_admits_bundle_naming_when_required() { + // Regression for the bundle-ring gap: load_ring used to hardcode + // KeyNaming::Env at every source leg, so POSTMASTER_KEY material + // never entered the ring and every bundle config failed at + // resolve_bundle time. With Bundle in the required namings, the + // environment source must admit it. + // + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + let var_name = "POSTMASTER_KEY_TESTLOADRING"; + unsafe { + std::env::set_var(var_name, "aa".repeat(32)); + } + let specs = vec![KeySourceSpec::Environment]; + let ring = load_ring(&specs, &[], &[KeyNaming::Bundle]); + unsafe { + std::env::remove_var(var_name); + } + let ring = + ring.expect("environment source must admit POSTMASTER_KEY* when Bundle is required"); + assert!(ring.candidates(var_name).is_some()); + } + + #[test] + fn load_ring_excludes_unrequired_namings() { + // Least privilege: a bundle config's ring must NOT carry + // DOTENV_PRIVATE_KEY* material just because the source provides + // it, and vice versa — the ring admits exactly the required set. + // + // SAFETY: set_var/remove_var are unsafe on edition 2024 (not + // thread-safe). Serialized via the process-wide ENV_LOCK + // (crate::test_util) shared by every env-mutating test. + let _guard = ENV_LOCK.lock().unwrap(); + let dotenv_var = "DOTENV_PRIVATE_KEY_TESTLOADRING"; + let bundle_var = "POSTMASTER_KEY_TESTLOADRING"; + unsafe { + std::env::set_var(dotenv_var, "bb".repeat(32)); + std::env::set_var(bundle_var, "cc".repeat(32)); + } + let specs = vec![KeySourceSpec::Environment]; + let ring = load_ring(&specs, &[], &[KeyNaming::Bundle]); + unsafe { + std::env::remove_var(dotenv_var); + std::env::remove_var(bundle_var); + } + let ring = ring.expect("bundle-required ring should load from POSTMASTER_KEY*"); + assert!(ring.candidates(bundle_var).is_some()); + assert!( + ring.candidates(dotenv_var).is_none(), + "an unrequired naming must not enter the ring" + ); + } +} diff --git a/src/keys/verify.rs b/src/keys/verify.rs new file mode 100644 index 0000000..99e86cb --- /dev/null +++ b/src/keys/verify.rs @@ -0,0 +1,127 @@ +//! Integrity verification of external binaries invoked on +//! secret-handling paths. + +use std::path::Path; + +/// Verify the integrity of an external binary before invoking it as part +/// of a secret-handling path. This is a defense-in-depth trust boundary +/// check (audit finding #3): the binary path is a trust root, and we +/// verify it has not been tampered with before exec. +/// +/// On macOS: `codesign -v ` verifies the code signature. The binary +/// is additionally SIP-protected, so this catches a hypothetical SIP +/// bypass or a misconfigured environment. +/// +/// On Linux: verify the file is a regular file, owned by root (uid 0), +/// and not world-writable. There is no universal code-signing mechanism +/// on Linux, so ownership and permission checks are the practical +/// defense-in-depth boundary. +/// +/// Bootstrap limitation (audit 2026-08-02 O2): on macOS the verifier +/// itself invokes `/usr/bin/codesign`. Self-verifying codesign with +/// codesign is circular — a compromised codesign would vouch for +/// itself — so codesign is trusted via SIP alone. Moving verification +/// onto the Security.framework StaticCode API (the `security-framework` +/// crate route already chosen for keychain access) would remove this +/// residual trust gap. +pub fn verify_external_binary(path: &Path) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("/usr/bin/codesign") + .args(["-v", path.to_str().unwrap_or("")]) + .output() + .map_err(|e| format!("{}: codesign verification failed: {e}", path.display()))?; + if !out.status.success() { + return Err(format!( + "{}: code signature verification failed (audit finding #3 trust boundary)", + path.display() + )); + } + } + #[cfg(not(target_os = "macos"))] + { + use std::os::unix::fs::MetadataExt; + let md = std::fs::symlink_metadata(path) + .map_err(|e| format!("{}: cannot stat external binary: {e}", path.display()))?; + if md.file_type().is_symlink() { + return Err(format!( + "{}: external binary is a symlink; refusing to trust a symlinked binary", + path.display() + )); + } + if !md.is_file() { + return Err(format!( + "{}: external binary is not a regular file", + path.display() + )); + } + if md.uid() != 0 { + return Err(format!( + "{}: external binary owned by uid {} (expected root/0); refusing to trust", + path.display(), + md.uid() + )); + } + if md.mode() & 0o002 != 0 { + return Err(format!( + "{}: external binary is world-writable; refusing to trust", + path.display() + )); + } + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic, unused_qualifications)] +mod tests { + use super::*; + + #[test] + #[cfg(target_os = "macos")] + fn verify_external_binary_accepts_codesigned_binary() { + // /usr/bin/security is Apple-signed and SIP-protected; the codesign + // verification must pass. This guards against a hypothetical SIP + // bypass or a misconfigured environment where the binary has been + // replaced. + verify_external_binary(std::path::Path::new("/usr/bin/security")) + .expect("codesigned system binary should pass verification"); + } + + #[test] + #[cfg(target_os = "macos")] + fn verify_external_binary_rejects_unsigned_file() { + // A temp file with no code signature must be rejected by codesign -v. + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "postmaster-test-unsigned-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::write(&path, b"not a signed binary").unwrap(); + let result = verify_external_binary(&path); + let _ = std::fs::remove_file(&path); + assert!( + result.is_err(), + "unsigned file should fail code signature verification" + ); + } + + #[test] + #[cfg(not(target_os = "macos"))] + fn verify_external_binary_rejects_non_root_owned() { + // On Linux, a non-root-owned binary in /tmp must be rejected. + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "postmaster-test-binary-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::write(&path, b"fake binary").unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let result = verify_external_binary(&path); + let _ = std::fs::remove_file(&path); + assert!(result.is_err(), "non-root-owned binary should be rejected"); + } +} diff --git a/src/launcher.rs b/src/launcher.rs index 3a96ed7..f6d9aa6 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -5,373 +5,170 @@ //! modules/lib.nix: values are opaque bytes end to end — no interpolation, //! no eval, no shell. -#![allow(missing_docs, missing_debug_implementations)] - use std::collections::HashMap; use std::ffi::OsString; use std::fs; -use std::io::Read; use std::os::unix::ffi::OsStringExt; -use std::os::unix::fs::MetadataExt; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; -use zeroize::Zeroizing; - use crate::adapter::KeyNaming; use crate::bundle::{parse_bundle, resolve_bundle}; -use crate::config::{ExecConfig, ExecMode, KeySourceSpec}; -use crate::keys::{KeyRing, resolve_all}; -use crate::server::{fetch_bytes, log}; - -type Resolved = Vec<(String, Zeroizing>)>; +use crate::exec_config::{ExecConfig, ExecMode}; +use crate::key_source::load_ring; +use crate::keys::resolve_all; +use crate::monitor::{self, STOP_SIGNALS}; +use crate::ramdisk::verify_ramdisk; +use crate::secret_files::{ + Resolved, cleanup_written, env_pairs, open_secrets_dir, prune_stale, write_secret_file, +}; +use crate::server::{fetch_bytes, log, read_capped}; + +/// Guard deferring termination signals across the window where plaintext +/// secret files exist on the ramdisk/tmpfs (audit 2026-08-02 A11). A +/// signal landing mid-write would otherwise strand partial plaintext, so +/// the four termination signals (`STOP_SIGNALS`) are *blocked* across the +/// window. The sibling monitor is then forked — it inherits the blocked +/// mask and keeps it for life (see monitor.rs) — and the parent restores +/// the saved mask before execvp so the service starts with a clean +/// signal state. +/// +/// The sigaction flag-handler and second pre-exec gate this guard used +/// to implement were retired with the sibling-monitor design +/// (2026-08-05): the monitor owns stop semantics, so no async handler is +/// needed anywhere. What remains is the deterministic write window +/// (`block`) and the pre-exec mask restore (`restore_mask`) — now real +/// on Linux too, because the fork+monitor model runs there as well. +#[cfg(target_os = "macos")] +pub(crate) struct TerminationGuard { + old: libc::sigset_t, +} -/// Ordered key-source selection. `credential` is skippable (no -/// $CREDENTIALS_DIRECTORY or file absent ⇒ try next source); every other -/// source is terminal: once selected, its failure fails the launcher. -// `wanted_vars` is only consumed by the macOS keychain source; on other -// targets the parameter is unused, so silence that leg's clippy warning -// without touching the signature or source-selection semantics. -#[cfg_attr(not(target_os = "macos"), allow(unused_variables))] -fn load_ring(specs: &[KeySourceSpec], wanted_vars: &[String]) -> Result { - for spec in specs { - match spec { - KeySourceSpec::Credential(name) => { - if let Some(dir) = std::env::var_os("CREDENTIALS_DIRECTORY") { - let p = Path::new(&dir).join(name); - // Use symlink_metadata instead of p.exists(): exists() - // follows symlinks, so a planted symlink at - // $CREDENTIALS_DIRECTORY/ pointing to a - // world-readable file would be silently followed. - // Reject symlinks outright (audit finding #2); the - // subsequent KeyRing::load also opens with O_NOFOLLOW - // as defense-in-depth. - match fs::symlink_metadata(&p) { - Ok(md) if md.file_type().is_symlink() => { - return Err(format!( - "{}: credential source is a symlink; refusing to follow (remove the symlink)", - p.display() - )); - } - Ok(md) if md.is_file() => return KeyRing::load(&p, KeyNaming::Env), - Ok(_) => { - return Err(format!( - "{}: credential source is not a regular file", - p.display() - )); - } - Err(_) => { /* absent: fall through to next source */ } - } - } - // absent credential: fall through to the next source - } - KeySourceSpec::File(p) => { - if p.starts_with("/nix/store") { - return Err(format!( - "{}: refusing plaintext private keys from the world-readable Nix store", - p.display() - )); - } - return KeyRing::load(p, KeyNaming::Env); - } - KeySourceSpec::Keychain(service) => { - #[cfg(target_os = "macos")] - return KeyRing::from_keychain(service, wanted_vars, KeyNaming::Env); - #[cfg(not(target_os = "macos"))] - return Err(format!( - "key source keychain({service}) is only available on macOS" - )); +#[cfg(target_os = "macos")] +impl TerminationGuard { + pub(crate) fn block() -> Self { + unsafe { + // SAFETY: zeroed sigset_t is a valid empty set representation; + // all pointers are valid and outlive the calls. + let mut set: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut set); + for sig in STOP_SIGNALS { + libc::sigaddset(&mut set, sig.as_raw()); } - KeySourceSpec::Environment => return KeyRing::from_env(KeyNaming::Env), + let mut old: libc::sigset_t = std::mem::zeroed(); + // pthread form: correct in single- and multi-threaded processes. + libc::pthread_sigmask(libc::SIG_BLOCK, &set, &mut old); + Self { old } } } - Err("no usable key source (configure keys=[…] or set DOTENV_PRIVATE_KEY*)".into()) -} -/// Pure parser for /sbin/mount output: is `mp` mounted as HFS? -fn mount_lists_hfs(mount_out: &str, mp: &Path) -> bool { - // Strip trailing slashes so a mount point like "/foo/" matches - // "/foo" in mount output (which never includes trailing slashes). - let mp_str = mp.to_string_lossy(); - let mp_trimmed = mp_str.trim_end_matches('/'); - let needle = format!(" on {mp_trimmed} (hfs"); - mount_out.lines().any(|l| l.contains(&needle)) + /// Restore the saved signal mask. The parent calls this before + /// execvp — the child must not inherit blocked termination signals. + pub(crate) fn restore_mask(&self) { + unsafe { + // SAFETY: restoring a previously saved valid mask. + libc::pthread_sigmask(libc::SIG_SETMASK, &self.old, std::ptr::null_mut()); + } + } } -/// Extract the device path for a mount point from `mount` output. -/// Format: `/dev/disk8 on /path (hfs, ...)` → `/dev/disk8`. -fn mount_device_for(mount_out: &str, mp: &Path) -> Option { - let mp_str = mp.to_string_lossy(); - let mp_trimmed = mp_str.trim_end_matches('/'); - let needle = format!(" on {mp_trimmed} (hfs"); - for line in mount_out.lines() { - if let Some(idx) = line.find(&needle) { - // The device is everything before " on " - let device = &line[..idx].trim(); - if !device.is_empty() { - return Some(device.to_string()); - } - } +#[cfg(target_os = "macos")] +impl Drop for TerminationGuard { + fn drop(&mut self) { + self.restore_mask(); } - None } -/// Verify that a device is backed by RAM (not a file-backed disk image). -/// Parses `hdiutil info` text output for the device's `image-path` -/// field and checks it starts with `ram://`. -fn device_is_ram_backed(device: &str, hdiutil_out: &str) -> bool { - // hdiutil info output structure (per image entry): - // image-path : ram://1024 - // image-type : read/write - // ... - // /dev/disk8 - // - // The device appears as a line by itself after the image properties. - // We find the device line, then scan backwards for the image-path - // field in the same image entry. +/// Linux leg of the guard (see the macOS docs above): same block/restore +/// contract via rustix's raw `rt_sigprocmask` — no libc FFI. +#[cfg(target_os = "linux")] +pub(crate) struct TerminationGuard { + /// `None` only if the block call failed (logged); restore then no-ops + /// rather than clobbering a mask that was never captured. + old: Option, +} - let lines: Vec<&str> = hdiutil_out.lines().collect(); - let device_line = device.trim(); - for (idx, line) in lines.iter().enumerate() { - if line.trim() == device_line { - // Scan backwards from the device line to find image-path - for prev in lines[..idx].iter().rev() { - let trimmed = prev.trim(); - if trimmed.starts_with("image-path") { - // Extract the value after "image-path : " - if let Some(colon_idx) = trimmed.find(':') { - let value = trimmed[colon_idx + 1..].trim(); - return value.starts_with("ram://"); - } - } - // If we hit another device line or a separator, this - // image entry's properties ended — stop scanning. - if trimmed.starts_with("/dev/") || trimmed.starts_with("====") { - break; - } +#[cfg(target_os = "linux")] +impl TerminationGuard { + pub(crate) fn block() -> Self { + let mut set = rustix::runtime::KernelSigSet::empty(); + for sig in STOP_SIGNALS { + set.insert(sig); + } + // SAFETY: the set holds only the four standard termination + // signals — none reserved by any libc in the process (glibc + // reserves 32/33, untouched). The launcher is single-threaded at + // this point (the exec path spawns no threads; worker threads + // exist only in serve mode). + match unsafe { + rustix::runtime::kernel_sigprocmask(rustix::runtime::How::BLOCK, Some(&set)) + } { + Ok(old) => Self { old: Some(old) }, + Err(e) => { + // rt_sigprocmask cannot realistically fail here (EINVAL/ + // EFAULT cover bad arguments, both impossible) — but if it + // ever does, log it and run unguarded rather than silently + // pretend the window is protected. + log(&format!("termination-guard: sigprocmask failed: {e}")); + Self { old: None } } - return false; } } - false -} -/// darwin ramdisk guard (was ramdiskGuard in modules/darwin.nix): plaintext -/// lands only on the postmaster ramdisk, in a directory this user owns. -fn verify_ramdisk(mount_point: &Path, secrets_dir: &Path) -> Result<(), String> { - let out = std::process::Command::new("/sbin/mount") - .output() - .map_err(|e| format!("/sbin/mount: {e}"))?; - let text = String::from_utf8_lossy(&out.stdout); - if !mount_lists_hfs(&text, mount_point) { - return Err(format!( - "{}: not the postmaster ramdisk; refusing to write plaintext to persistent disk", - mount_point.display() - )); - } - // Verify the mount is RAM-backed, not a file-backed HFS image. - // Extract the device from mount output, then check hdiutil info - // for that device's image-path starting with ram://. - let device = mount_device_for(&text, mount_point).ok_or_else(|| { - format!( - "{}: could not determine backing device from mount output", - mount_point.display() - ) - })?; - let hdiutil_out = std::process::Command::new("/usr/bin/hdiutil") - .arg("info") - .output() - .map_err(|e| format!("hdiutil info: {e}"))?; - let hdiutil_text = String::from_utf8_lossy(&hdiutil_out.stdout); - if !device_is_ram_backed(&device, &hdiutil_text) { - return Err(format!( - "{}: mount device {device} is not RAM-backed (image-path does not start with ram://); refusing to write plaintext to a file-backed disk image", - mount_point.display() - )); - } - // Use symlink_metadata instead of metadata: if secrets_dir is itself a - // symlink to persistent disk (planted by a prior compromised launch on - // the darwin ramdisk, which persists across restarts), fs::metadata would - // follow the link and inspect the TARGET — a directory owned by the - // service user on persistent disk — and approve it. Reject symlinks - // outright so the ramdisk invariant is not bypassed. - let md = fs::symlink_metadata(secrets_dir).map_err(|e| { - format!( - "{}: {e} (is postmaster-ramdisk healthy?)", - secrets_dir.display() - ) - })?; - if md.file_type().is_symlink() { - return Err(format!( - "{}: secrets_dir is a symlink; refusing to follow — remove the symlink and ensure the directory is on the ramdisk", - secrets_dir.display() - )); - } - if !md.is_dir() || md.uid() != rustix::process::geteuid().as_raw() { - return Err(format!( - "{}: missing or not owned by this service user (is postmaster-ramdisk healthy?)", - secrets_dir.display() - )); - } - Ok(()) -} - -/// Resolved values → env pairs. Env values cannot hold NUL; fail closed -/// rather than truncate. -fn env_pairs(resolved: &Resolved) -> Result, String> { - let mut out = Vec::with_capacity(resolved.len()); - for (k, v) in resolved { - if v.contains(&0) { - return Err(format!( - "{k}: value contains a NUL byte; cannot be an environment variable (use files mode)" - )); + /// Restore the saved signal mask (see the macOS docs). + pub(crate) fn restore_mask(&self) { + if let Some(old) = &self.old { + // SAFETY: restoring a mask previously returned by the kernel + // for this thread. + let _ = unsafe { + rustix::runtime::kernel_sigprocmask(rustix::runtime::How::SETMASK, Some(old)) + }; } - out.push((OsString::from(k.clone()), OsString::from_vec(v.to_vec()))); } - Ok(out) } -/// Writes `value` to `dir.join(key)`. Invariant this enforces: plaintext -/// lands only on the mount `verify_ramdisk` already checked — a symlink -/// planted at the target path (e.g. by leftover/attacker-controlled state -/// in a per-service ramdisk dir that persists across restarts) must never -/// be followed onto persistent disk, so the open refuses symlinks -/// (`O_NOFOLLOW`) and fails closed instead. -fn write_secret_file(dir: &Path, key: &str, value: &[u8]) -> Result { - use std::io::Write; - let path = dir.join(key); - let oflags = rustix::fs::OFlags::WRONLY - | rustix::fs::OFlags::CREATE - | rustix::fs::OFlags::TRUNC - | rustix::fs::OFlags::NOFOLLOW - | rustix::fs::OFlags::CLOEXEC; - let fd = rustix::fs::open(&path, oflags, rustix::fs::Mode::from_bits_truncate(0o600)) - .map_err(|e| format!("{}: {e} (refusing to follow a symlink)", path.display()))?; - // Use fchmod on the fd (not set_permissions on the path) to eliminate a - // TOCTOU: between open(O_NOFOLLOW) and a path-based chmod, an attacker - // with directory write access could unlink the file and create a symlink - // at the same path. fchmod operates on the already-opened fd, which is - // the file we just created — no path re-resolution occurs. - rustix::fs::fchmod(&fd, rustix::fs::Mode::from_bits_truncate(0o600)) - .map_err(|e| format!("{}: fchmod: {e}", path.display()))?; - let mut f = fs::File::from(fd); - f.write_all(value) - .and_then(|()| f.flush()) - .map_err(|e| format!("{}: write: {e}", path.display()))?; - Ok(path) +#[cfg(target_os = "linux")] +impl Drop for TerminationGuard { + fn drop(&mut self) { + self.restore_mask(); + } } -/// Unlink all paths in `written` (best-effort) after a mid-loop failure so -/// stale plaintext files do not persist on the ramdisk/tmpfs. The caller -/// must only push successfully-written secret file paths here. -fn cleanup_written(written: &[PathBuf]) { - for p in written { - let _ = fs::remove_file(p); - } +/// Read and deserialize an ExecConfig from a JSON file. Shared by +/// `check_config` (validate-only) and `exec` (validate + materialize). +pub fn parse_exec_config(config_path: &Path) -> Result { + let file = + fs::File::open(config_path).map_err(|e| format!("{}: {e}", config_path.display()))?; + let raw = read_capped(file, config_path)?; + serde_json::from_slice(&raw).map_err(|e| format!("{}: {e}", config_path.display())) } -/// Prune stale secret files from `dir` that are not in `keep_keys`. This -/// removes plaintext files left behind by a previous launch whose config -/// has since changed (a key removed from credential_keys or a resolved key -/// set that no longer includes a prior entry). Best-effort: logs but does -/// not fail if a file cannot be removed, since the primary invariant — -/// plaintext is transient — is best served by cleaning what we can rather -/// than aborting the launch over an unrelated stale file. -fn prune_stale(dir: &Path, keep_keys: &[String]) -> Result<(), String> { - let keep: std::collections::HashSet<&str> = keep_keys.iter().map(String::as_str).collect(); - let entries = match fs::read_dir(dir) { - Ok(e) => e, - // If the directory does not exist yet, there is nothing to prune. - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), - Err(e) => return Err(format!("{}: prune: {e}", dir.display())), - }; - for entry in entries { - let entry = match entry { - Ok(e) => e, - Err(e) => { - log(&format!( - "{}: prune: skip unreadable entry: {e}", - dir.display() - )); - continue; - } - }; - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - if keep.contains(name_str) { - continue; - } - // Only unlink regular files; skip subdirectories and symlinks to - // avoid following attacker-controlled links in a shared dir. - let path = entry.path(); - match fs::symlink_metadata(&path) { - Ok(md) if md.is_file() => { - if let Err(e) = fs::remove_file(&path) { - log(&format!("{}: prune: {e}", path.display())); - } - } - Ok(md) => { - log(&format!( - "{}: prune: skipping non-file (type {:?})", - path.display(), - md.file_type() - )); - } - Err(e) => { - log(&format!("{}: prune: stat: {e}", path.display())); - } - } - } - Ok(()) +/// Validate the public shape of an exec config without decrypting, +/// reading key material, or touching the host filesystem. This is +/// the `--check` contract for CI and automation tools. +pub fn check_config(config_path: &Path) -> Result<(), String> { + let cfg = parse_exec_config(config_path)?; + cfg.validate_shape() } -/// Preflight key freshness check (audit finding #4): verify that the -/// loaded key ring can actually decrypt at least one encrypted value -/// from the env files. Catches stale keys from a previous rotation -/// cycle that are valid hex but no longer match the ciphertext. Without -/// this check, `strict: false` would silently skip undecryptable values -/// and launch the child with no secrets. +/// Preflight key freshness check: fail if any encrypted env value was +/// skipped during resolution because the key ring could not decrypt it +/// (audit 2026-08-02 A3). Under `strict: false` a partial key rotation +/// would otherwise pass silently here — the old check only caught total +/// failure (empty resolved set) — and the child would launch with a +/// partial secret set. The failure list recorded by `resolve_all` makes +/// every skip visible to `--verify-keys`. /// -/// The check scans each env file for the first `encrypted:` value and -/// attempts a test decryption. If no encrypted values exist (all -/// plaintext passthrough), the check passes trivially. -fn verify_key_freshness(resolved: &Resolved, env_files: &[PathBuf]) -> Result<(), String> { - // If we resolved at least one value, the keys are fresh enough. - // An empty resolved vec with encrypted values in the env files means - // the keys are stale (all decryptions failed under strict:false). - if !resolved.is_empty() { - return Ok(()); - } - // resolved is empty: check whether any env file contains encrypted - // values. If none do (all plaintext), the empty result is expected. - for env_file in env_files { - let iter = match dotenvy::from_path_iter(env_file) { - Ok(it) => it, - Err(e) => return Err(format!("{}: {e}", env_file.display())), - }; - for item in iter { - let (k, v) = match item { - Ok(p) => p, - Err(e) => return Err(format!("{}: {e}", env_file.display())), - }; - if let Some(prefix) = KeyNaming::Env.metadata_prefix() - && k.starts_with(prefix) - { - continue; - } - if v.starts_with("encrypted:") { - // Found an encrypted value but resolved is empty: the - // keys are stale and could not decrypt it. - return Err(format!( - "{}: --verify-keys failed: key ring could not decrypt {k} (stale keys from a previous rotation?)", - env_file.display() - )); - } - } +/// Bundle files need no scan here: `resolve_bundle` is all-or-nothing — +/// the first undecryptable entry aborts `decrypt()` regardless of +/// `strict`, so stale bundle keys already fail closed before this check +/// runs (audit 2026-08-02 A4). +fn verify_key_freshness(failed: &[(String, String)]) -> Result<(), String> { + if let Some((k, _)) = failed.first() { + return Err(format!( + "--verify-keys failed: key ring could not decrypt {k} (stale keys from a previous rotation?)" + )); } - // No encrypted values found: the empty resolved vec is expected. Ok(()) } @@ -395,7 +192,10 @@ fn validate_credential_keys(keys: &[String]) -> Result<(), String> { Ok(()) } -fn decrypt(cfg: &ExecConfig) -> Result { +/// Decrypt all configured inputs. Returns the resolved (name, plaintext) +/// pairs plus the `(name, error)` list of encrypted env values skipped +/// under `strict: false` (consumed by `verify_key_freshness`). +fn decrypt(cfg: &ExecConfig) -> Result<(Resolved, Vec<(String, String)>), String> { if cfg.env_files.is_empty() && cfg.bundle_files.is_empty() { return Err("exec config lists no env_files or bundle_files".into()); } @@ -408,25 +208,41 @@ fn decrypt(cfg: &ExecConfig) -> Result { for f in &cfg.bundle_files { wanted.push(KeyNaming::Bundle.key_var_for(f)?); } - let ring = load_ring(&cfg.keys, &wanted)?; + let ring = load_ring(&cfg.keys, &wanted, &cfg.required_namings())?; - // Resolve .env files (if any) via the env adapter - let mut resolved: Resolved = if cfg.env_files.is_empty() { - Vec::new() + // Resolve .env files (if any) via the env adapter. Values skipped + // under strict:false are recorded in `failed` so --verify-keys can + // fail closed on a partial key rotation (audit 2026-08-02 A3). + let (mut resolved, failed): (Resolved, Vec<(String, String)>) = if cfg.env_files.is_empty() { + (Vec::new(), Vec::new()) } else { - resolve_all( + let r = resolve_all( &cfg.env_files, &ring, cfg.overload, cfg.strict, &|k| std::env::var_os(k).is_some(), KeyNaming::Env, - )? + )?; + (r.resolved, r.failed) }; // Resolve bundle files (if any) via the bundle adapter for bundle_path in &cfg.bundle_files { - let raw = fs::read(bundle_path).map_err(|e| format!("{}: {e}", bundle_path.display()))?; + // O_NOFOLLOW on the ciphertext open (audit 2026-08-02 A13): + // refuse to read through a planted symlink. + let bundle_fd = rustix::fs::open( + bundle_path, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|e| { + format!( + "{}: {e} (refusing to follow a symlink)", + bundle_path.display() + ) + })?; + let raw = read_capped(fs::File::from(bundle_fd), bundle_path)?; let bundle = parse_bundle(&raw, bundle_path)?; let bundle_resolved = resolve_bundle(&bundle, &ring, bundle_path)?; for (name, value, encoding) in bundle_resolved { @@ -451,539 +267,42 @@ fn decrypt(cfg: &ExecConfig) -> Result { } } - Ok(resolved) -} - -pub fn check_config(config_path: &Path) -> Result<(), String> { - let cfg = parse_exec_config(config_path)?; - cfg.validate_shape() -} - -/// `postmaster setup`: verify platform prerequisites, create keys -/// directory with correct permissions, optionally seed keychain items, -/// and validate the environment. Idempotent — skips steps that are -/// already correct. -/// -/// `--recheck`: re-run verification checks only, without creating or -/// modifying anything. Equivalent to the old `setup_recheck`. -/// -/// `--unattended`: non-interactive mode for automation. Uses defaults -/// or env vars instead of prompting. Fail closed on any missing -/// prerequisite. -/// -/// `--force`: re-seed or re-create even if the target state is already -/// present. -/// -/// `--keys-dir `: override the keys directory path. Default is -/// `postmaster cleanup`: remove all secret files from the secrets_dir -/// configured in the exec config. Intended for `ExecStopPost` in systemd -/// or equivalent lifecycle hooks. Does not decrypt — only removes files -/// that postmaster would have written. Safe to run even if the directory -/// is empty or does not exist. -pub fn cleanup(config_path: &Path) -> Result<(), String> { - let cfg = parse_exec_config(config_path)?; - let dir = cfg - .secrets_dir - .as_deref() - .ok_or("cleanup: config has no secrets_dir — nothing to clean up")?; - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - log(&format!( - "cleanup: {} does not exist; nothing to clean up", - dir.display() - )); - return Ok(()); - } - Err(e) => return Err(format!("{}: cleanup: {e}", dir.display())), - }; - let mut removed = 0_usize; - for entry in entries { - let entry = match entry { - Ok(e) => e, - Err(e) => { - log(&format!( - "{}: cleanup: skip unreadable entry: {e}", - dir.display() - )); - continue; - } - }; - let path = entry.path(); - match fs::symlink_metadata(&path) { - Ok(md) if md.is_file() => { - if let Err(e) = fs::remove_file(&path) { - log(&format!("{}: cleanup: {e}", path.display())); - } else { - removed += 1; - } - } - Ok(md) => { - log(&format!( - "{}: cleanup: skipping non-file (type {:?})", - path.display(), - md.file_type() - )); - } - Err(e) => { - log(&format!("{}: cleanup: stat: {e}", path.display())); - } - } - } - log(&format!( - "cleanup: removed {removed} file(s) from {}", - dir.display() - )); - Ok(()) -} - -/// Default keys directory path: `/var/lib/postmaster` on Linux, -/// `~/Library/Application Support/postmaster` on macOS. -pub fn setup( - config_path: &Path, - recheck: bool, - unattended: bool, - force: bool, - keys_dir_override: Option, -) -> Result<(), String> { - let cfg = parse_exec_config(config_path)?; - cfg.validate_shape()?; - - // Build the wanted key variable list from both env and bundle files - let mut wanted: Vec = Vec::new(); - for f in &cfg.env_files { - wanted.push(KeyNaming::Env.key_var_for(f)?); - } - for f in &cfg.bundle_files { - wanted.push(KeyNaming::Bundle.key_var_for(f)?); - } - - // Step 1: Verify platform prerequisites - verify_platform_prerequisites(&cfg, unattended)?; - - if recheck { - // --recheck: verification only, no creation or modification - return verify_key_sources(&cfg, &wanted); - } - - // Step 2: Create or verify keys directory - let keys_dir = keys_dir_override.unwrap_or_else(default_keys_dir); - ensure_keys_dir(&keys_dir, force)?; - - // Step 3: Verify or create key files for file-source keys - for spec in &cfg.keys { - if let KeySourceSpec::File(path) = spec { - ensure_keys_file(path, force, unattended)?; - } - } - - // Step 4: Verify or seed keychain items (macOS only) - #[cfg(target_os = "macos")] - for spec in &cfg.keys { - if let KeySourceSpec::Keychain(service) = spec { - ensure_keychain_items(service, &wanted, force, unattended)?; - } - } - - // Step 5: Verify the full environment (same as --recheck) - verify_key_sources(&cfg, &wanted)?; - - log(&format!( - "setup: complete — keys dir {}, {} key source(s) verified", - keys_dir.display(), - cfg.keys.len() - )); - Ok(()) -} - -/// Default keys directory path: `/var/lib/postmaster` on Linux, -/// `~/Library/Application Support/postmaster` on macOS. -fn default_keys_dir() -> PathBuf { - #[cfg(target_os = "macos")] - { - if let Some(home) = std::env::var_os("HOME") { - return PathBuf::from(home) - .join("Library") - .join("Application Support") - .join("postmaster"); - } - } - PathBuf::from("/var/lib/postmaster") -} - -/// Verify platform prerequisites: codesign check on macOS, root-owned -/// binary check on Linux. Fails closed in unattended mode if the -/// external binary can't be verified. -fn verify_platform_prerequisites(cfg: &ExecConfig, _unattended: bool) -> Result<(), String> { - // Only check if keychain source is configured (the only source that - // invokes an external binary) - let has_keychain = cfg - .keys - .iter() - .any(|k| matches!(k, KeySourceSpec::Keychain(_))); - if !has_keychain { - return Ok(()); - } - - #[cfg(target_os = "macos")] - { - // Verify /usr/bin/security code signature - crate::keys::verify_external_binary(Path::new("/usr/bin/security")) - .map_err(|e| format!("platform prerequisite: {e}"))?; - log("setup: platform prerequisite verified (codesign /usr/bin/security)"); - } - - #[cfg(not(target_os = "macos"))] - { - let _ = unattended; - log("setup: no platform-specific prerequisites on this OS"); - } - - Ok(()) -} - -/// Create the keys directory with 0700 permissions if it doesn't exist. -/// If it exists, verify permissions and ownership are correct. Idempotent -/// — skips if already correct unless `force` is true. -fn ensure_keys_dir(keys_dir: &Path, _force: bool) -> Result<(), String> { - if keys_dir.exists() { - // Verify it's a directory, not a symlink - let md = - fs::symlink_metadata(keys_dir).map_err(|e| format!("{}: {e}", keys_dir.display()))?; - if md.is_symlink() { - return Err(format!( - "{}: refusing to use a symlink as keys directory", - keys_dir.display() - )); - } - if !md.is_dir() { - return Err(format!( - "{}: exists but is not a directory", - keys_dir.display() - )); - } - // Verify permissions: 0700 (rwx------) - use std::os::unix::fs::PermissionsExt; - let mode = md.permissions().mode(); - if mode & 0o077 != 0 { - // Fix permissions if we can - fs::set_permissions(keys_dir, fs::Permissions::from_mode(0o700)) - .map_err(|e| format!("{}: cannot fix permissions: {e}", keys_dir.display()))?; - log(&format!( - "{}: permissions corrected to 0700", - keys_dir.display() - )); - } - log(&format!("{}: keys directory verified", keys_dir.display())); - return Ok(()); - } - - // Create the directory with 0700 - fs::create_dir_all(keys_dir) - .map_err(|e| format!("{}: cannot create keys directory: {e}", keys_dir.display()))?; - use std::os::unix::fs::PermissionsExt; - fs::set_permissions(keys_dir, fs::Permissions::from_mode(0o700)) - .map_err(|e| format!("{}: cannot set permissions: {e}", keys_dir.display()))?; - log(&format!( - "{}: keys directory created with 0700", - keys_dir.display() - )); - Ok(()) -} - -/// Verify a keys file exists with correct permissions (0600, owned by -/// euid). If the file doesn't exist, in interactive mode, prompt the -/// operator to create it. In unattended mode, fail closed. If the file -/// exists but has wrong permissions, fix them. -#[allow(clippy::print_stderr)] -fn ensure_keys_file(path: &Path, _force: bool, unattended: bool) -> Result<(), String> { - if path.exists() { - // Verify permissions and ownership (same checks as setup_recheck) - let oflags = - rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC; - let fd = rustix::fs::open(path, oflags, rustix::fs::Mode::empty()) - .map_err(|e| format!("{}: {e} (refusing to follow a symlink)", path.display()))?; - let stat = rustix::fs::fstat(&fd).map_err(|e| format!("{}: fstat: {e}", path.display()))?; - let mode = rustix::fs::Mode::from_bits_truncate(stat.st_mode); - if mode.intersects(rustix::fs::Mode::RWXG | rustix::fs::Mode::RWXO) { - // Fix permissions - rustix::fs::fchmod(&fd, rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR) - .map_err(|e| format!("{}: cannot fix permissions: {e}", path.display()))?; - log(&format!( - "{}: permissions corrected to 0600", - path.display() - )); - } - let euid = rustix::process::geteuid().as_raw(); - if stat.st_uid != euid { - return Err(format!( - "{}: owned by uid {} but postmaster euid is {euid} (cannot fix ownership automatically)", - path.display(), - stat.st_uid - )); - } - log(&format!("{}: keys file verified", path.display())); - return Ok(()); - } - - // File doesn't exist - if unattended { - return Err(format!( - "{}: keys file not found (unattended mode — cannot prompt; create the file manually before running setup)", - path.display() - )); - } - - // Interactive: prompt the operator - eprintln!("{}: keys file not found.", path.display()); - eprintln!( - "Create it now? You will need to paste the key contents (DOTENV_PRIVATE_KEY=<64-hex>...). [y/N] " - ); - let mut input = String::new(); - if std::io::stdin().read_line(&mut input).is_err() { - return Err(format!("{}: cannot read input", path.display())); - } - if !input.trim().eq_ignore_ascii_case("y") { - return Err(format!( - "{}: operator declined to create keys file", - path.display() - )); - } - - eprintln!("Paste the keys file contents (Ctrl+D to finish):"); - let mut contents = String::new(); - if std::io::stdin().read_to_string(&mut contents).is_err() { - return Err(format!("{}: cannot read key contents", path.display())); - } - - // Write the file with 0600 - use std::os::unix::fs::OpenOptionsExt; - let mut file = fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(path) - .map_err(|e| format!("{}: cannot create: {e}", path.display()))?; - use std::io::Write; - file.write_all(contents.as_bytes()) - .map_err(|e| format!("{}: cannot write: {e}", path.display()))?; - log(&format!("{}: keys file created with 0600", path.display())); - Ok(()) -} - -/// Verify keychain items exist for the wanted key variables. If they -/// don't exist and we're in interactive mode, offer to seed them. -/// On macOS only. -#[cfg(target_os = "macos")] -#[allow(clippy::print_stderr)] -fn ensure_keychain_items( - service: &str, - wanted: &[String], - force: bool, - unattended: bool, -) -> Result<(), String> { - let mut missing: Vec<&str> = Vec::new(); - for var in wanted { - let out = std::process::Command::new("/usr/bin/security") - .args(["find-generic-password", "-s", service, "-a", var, "-w"]) - .output() - .map_err(|e| format!("security: {e}"))?; - if !out.status.success() { - missing.push(var); - } - } - - if missing.is_empty() && !force { - log(&format!( - "keychain {service}: all {wanted_len} item(s) verified", - wanted_len = wanted.len() - )); - return Ok(()); - } - - if unattended && !missing.is_empty() { - return Err(format!( - "keychain {service}: {missing_len} item(s) missing (unattended mode — cannot prompt)", - missing_len = missing.len() - )); - } - - // Interactive: offer to seed missing items - if !missing.is_empty() { - eprintln!( - "keychain {service}: missing items for: {}", - missing.join(", ") - ); - eprintln!("Seed them now? You will need to paste each key value. [y/N] "); - let mut input = String::new(); - if std::io::stdin().read_line(&mut input).is_err() { - return Err("cannot read input".into()); - } - if !input.trim().eq_ignore_ascii_case("y") { - return Err(format!( - "keychain {service}: operator declined to seed missing items" - )); - } - - for var in &missing { - eprint!("Paste value for {var}: "); - let mut value = String::new(); - if std::io::stdin().read_line(&mut value).is_err() { - return Err(format!("cannot read value for {var}")); - } - let value = value.trim(); - if value.is_empty() { - return Err(format!("{var}: empty value, refusing to seed")); - } - let out = std::process::Command::new("/usr/bin/security") - .args([ - "add-generic-password", - "-s", - service, - "-a", - var, - "-w", - value, - ]) - .output() - .map_err(|e| format!("security: {e}"))?; - if !out.status.success() { - return Err(format!( - "keychain {service}: failed to seed {var}: {}", - String::from_utf8_lossy(&out.stderr) - )); - } - log(&format!("keychain {service}: seeded {var}")); - } - } - - Ok(()) -} - -/// Verify that all configured key sources are accessible. This is the -/// `--recheck` path — same as the old `setup_recheck` function. -fn verify_key_sources(cfg: &ExecConfig, wanted: &[String]) -> Result<(), String> { - let mut checked = 0_u32; - let mut found = 0_u32; - - for spec in &cfg.keys { - match spec { - KeySourceSpec::Credential(name) => { - checked += 1; - let dir = std::env::var_os("CREDENTIALS_DIRECTORY") - .ok_or("$CREDENTIALS_DIRECTORY is not set")?; - let p = Path::new(&dir).join(name); - if !p.exists() { - return Err(format!( - "{}: credential source not found under $CREDENTIALS_DIRECTORY", - p.display() - )); - } - found += 1; - } - KeySourceSpec::File(path) => { - checked += 1; - if path.starts_with("/nix/store") { - return Err(format!( - "{}: refusing plaintext private keys from the world-readable Nix store", - path.display() - )); - } - let oflags = rustix::fs::OFlags::RDONLY - | rustix::fs::OFlags::NOFOLLOW - | rustix::fs::OFlags::CLOEXEC; - let fd = - rustix::fs::open(path, oflags, rustix::fs::Mode::empty()).map_err(|e| { - format!("{}: {e} (refusing to follow a symlink)", path.display()) - })?; - let stat = rustix::fs::fstat(&fd) - .map_err(|e| format!("{}: fstat: {e}", path.display()))?; - let mode = rustix::fs::Mode::from_bits_truncate(stat.st_mode); - if mode.intersects(rustix::fs::Mode::RWXG | rustix::fs::Mode::RWXO) { - return Err(format!( - "{}: permissions {:#o} are group/world accessible; expected 0400 or 0600", - path.display(), - stat.st_mode - )); - } - let euid = rustix::process::geteuid().as_raw(); - if stat.st_uid != euid { - return Err(format!( - "{}: owned by uid {} but postmaster euid is {euid}", - path.display(), - stat.st_uid - )); - } - found += 1; - } - KeySourceSpec::Keychain(service) => { - checked += 1; - #[cfg(target_os = "macos")] - { - for var in wanted { - let out = std::process::Command::new("/usr/bin/security") - .args(["find-generic-password", "-s", service, "-a", var, "-w"]) - .output() - .map_err(|e| format!("security: {e}"))?; - if !out.status.success() { - return Err(format!( - "keychain {service}: no item for account {var} (fail closed)" - )); - } - } - found += 1; - } - #[cfg(not(target_os = "macos"))] - { - let _ = service; - return Err("keychain source is only available on macOS".into()); - } - } - KeySourceSpec::Environment => { - checked += 1; - let env_prefix = KeyNaming::Env.key_prefix(); - let bundle_prefix = KeyNaming::Bundle.key_prefix(); - let has_key = std::env::vars() - .any(|(k, _)| k.starts_with(env_prefix) || k.starts_with(bundle_prefix)); - if !has_key { - return Err(format!( - "environment: no {env_prefix}* or {bundle_prefix}* variables set" - )); - } - found += 1; - } - } - } - - if checked == 0 { - return Err("no key sources configured (configure keys=[…])".into()); - } - - log(&format!( - "setup: {found}/{checked} key source(s) verified — environment ready" - )); - Ok(()) -} - -/// Read and deserialize an ExecConfig from a JSON file. Shared by -/// `check_config` (validate-only) and `exec` (validate + materialize). -fn parse_exec_config(config_path: &Path) -> Result { - let raw = fs::read(config_path).map_err(|e| format!("{}: {e}", config_path.display()))?; - serde_json::from_slice(&raw).map_err(|e| format!("{}: {e}", config_path.display())) + Ok((resolved, failed)) } +/// Decrypt configured secret inputs, inject them into the child +/// context, and `execvp` the target binary. The process image is +/// replaced — this function never returns on success. pub fn exec(config_path: &Path, argv: &[String], verify_keys: bool) -> Result<(), String> { let cfg = parse_exec_config(config_path)?; cfg.validate_shape()?; + let program = argv.first().ok_or("exec: empty argv")?; + // Defer termination signals until the fork/exec below, so a signal + // landing mid-write can't strand plaintext on disk (audit 2026-08-02 + // A11 — see TerminationGuard docs). The fork-child monitor inherits + // the blocked mask and keeps it for life; the parent restores the + // saved mask before execvp. + let sig_guard = TerminationGuard::block(); let mut extra: Vec<(OsString, OsString)> = Vec::new(); + // Names of plaintext files written beneath the pinned secrets-dir + // fd, tracked across the match so the failure paths (and the monitor, + // post-fork) can unlink them fd-relative (audit 2026-08-02 A9). + let mut written: Vec = Vec::new(); + // The pinned secrets-dir fd (Files / Credentials-fetch modes only). + // Opened ONCE with O_DIRECTORY|O_NOFOLLOW|O_RDONLY|CLOEXEC and kept + // until after the exec-failure cleanup, so every write and every + // cleanup unlink is relative to the same pinned inode (audit + // 2026-08-02 A12). Across the fork the monitor inherits the fd + // (CLOEXEC fires only on exec, which the monitor never performs) and + // holds it for the service's lifetime. + let mut secrets_dir_fd: Option = None; match cfg.mode { ExecMode::Env => { - let resolved = decrypt(&cfg)?; + let (resolved, failed) = decrypt(&cfg)?; if verify_keys { - verify_key_freshness(&resolved, &cfg.env_files)?; + verify_key_freshness(&failed)?; } extra = env_pairs(&resolved)?; // resolved (Zeroizing> values) dropped here — @@ -1016,27 +335,32 @@ pub fn exec(config_path: &Path, argv: &[String], verify_keys: bool) -> Result<() } } } - let resolved = decrypt(&cfg)?; + let (resolved, failed) = decrypt(&cfg)?; if verify_keys { - verify_key_freshness(&resolved, &cfg.env_files)?; + verify_key_freshness(&failed)?; } // Prune stale secret files from a prior launch before writing new ones. let keep_keys: Vec = resolved.iter().map(|(k, _)| k.clone()).collect(); prune_stale(dir, &keep_keys)?; - let mut written: Vec = Vec::with_capacity(resolved.len()); + // Pin the secrets dir for the whole write window: the writes, + // the mid-loop cleanups, and the post-match cleanups all act + // relative to this one pinned fd. + let dir_fd = open_secrets_dir(dir) + .map_err(|e| format!("{}: {e} (refusing to follow a symlink)", dir.display()))?; + written.reserve(resolved.len()); for (k, v) in &resolved { - let path = match write_secret_file(dir, k, v) { + let path = match write_secret_file(&dir_fd, dir, k, v) { Ok(p) => p, Err(e) => { - cleanup_written(&written); + cleanup_written(&dir_fd, &written); return Err(e); } }; - written.push(path.clone()); + written.push(k.clone()); extra.push((OsString::from(format!("{k}_FILE")), path.into_os_string())); if cfg.passthrough.iter().any(|p| p == k) { if v.contains(&0) { - cleanup_written(&written); + cleanup_written(&dir_fd, &written); return Err(format!("{k}: passthrough value contains NUL")); } extra.push((OsString::from(k.clone()), OsString::from_vec(v.to_vec()))); @@ -1047,6 +371,7 @@ pub fn exec(config_path: &Path, argv: &[String], verify_keys: bool) -> Result<() // (extra: OsString pairs) survive to execvp, not the // plaintext values themselves. drop(resolved); + secrets_dir_fd = Some(dir_fd); } ExecMode::Credentials => { if cfg.credential_keys.is_empty() { @@ -1076,7 +401,12 @@ pub fn exec(config_path: &Path, argv: &[String], verify_keys: bool) -> Result<() let sockets: &HashMap = &cfg.sockets; // Prune stale credential files from a prior launch. prune_stale(dir, &cfg.credential_keys)?; - let mut written: Vec = Vec::with_capacity(cfg.credential_keys.len()); + // Pin the secrets dir for the whole fetch/write window + // (same single-fd discipline as files mode). + let dir_fd = open_secrets_dir(dir).map_err(|e| { + format!("{}: {e} (refusing to follow a symlink)", dir.display()) + })?; + written.reserve(cfg.credential_keys.len()); for k in &cfg.credential_keys { let sock = sockets .get(k) @@ -1084,33 +414,94 @@ pub fn exec(config_path: &Path, argv: &[String], verify_keys: bool) -> Result<() let bytes = match fetch_bytes(sock) { Ok(b) => b, Err(e) => { - cleanup_written(&written); + cleanup_written(&dir_fd, &written); return Err(e); } }; - let path = match write_secret_file(dir, k, &bytes) { + let path = match write_secret_file(&dir_fd, dir, k, &bytes) { Ok(p) => p, Err(e) => { - cleanup_written(&written); + cleanup_written(&dir_fd, &written); return Err(e); } }; - written.push(path.clone()); + written.push(k.clone()); extra.push((OsString::from(format!("{k}_FILE")), path.into_os_string())); } extra.push(( OsString::from("CREDENTIALS_DIRECTORY"), dir.as_os_str().to_os_string(), )); + secrets_dir_fd = Some(dir_fd); } } } - let program = argv.first().ok_or("exec: empty argv")?; - let err = std::process::Command::new(program) - .args(&argv[1..]) - .envs(extra) - .exec(); // only returns on failure + // Zeroization is complete at this point (the key ring dropped inside + // decrypt(); `resolved` dropped at the end of each arm above): fork + // the sibling monitor BEFORE exec. The fork-child keeps the guard's + // blocked mask for life and supervises this process — stop-signal + // forwarding, bounded grace, SIGKILL escalation, and plaintext cleanup + // on every death path (sibling-monitor design 2026-08-05; see + // monitor.rs). The parent execs the service exactly as before, so + // MainPID is unchanged. The monitor runs for ALL exec launches, + // including env mode (uniform semantics; its cleanup is a no-op + // there). The pid is captured pre-fork: post-fork the parent IS the + // service-to-be, so the monitor needs no racy getppid. + let service_pid = rustix::process::getpid(); + // SAFETY: fork(2) in a single-threaded process (the exec path spawns + // no threads; the daemon's worker threads exist only in serve mode). + // The fork-child executes only monitor::run — the async-signal-safe + // syscall set documented in monitor.rs: no allocation, no stdio + // locks, no destructors — and exits via _exit/exit_group. rustix + // deliberately has no fork wrapper; on Linux this is the one libc FFI + // call on the exec path, flagged in docs/src/unsafe-code/monitor.md. + match unsafe { libc::fork() } { + -1 => { + // No monitor means no stop semantics and no cleanup on + // abnormal death: fail closed like an exec failure rather + // than launch unsupervised. + if let Some(dir_fd) = &secrets_dir_fd { + cleanup_written(dir_fd, &written); + } + let err = std::io::Error::last_os_error(); + log(&format!("exec {program}: fork: {err}")); + return Err(format!("exec {program}: fork: {err}")); + } + 0 => { + // Fork-child = the monitor (never returns). sig_guard is + // deliberately NOT dropped here: monitor::run diverges + // (`-> !`) and the _exit path runs no destructors, so the + // termination mask stays blocked for the monitor's life + // (run() re-asserts the block regardless). The inherited + // `extra` env pairs are SCRUBBED first — they hold plaintext + // values in env mode (and files-mode passthrough), and + // free() alone would leave the bytes in the monitor's + // mapped pages for its whole life (threat model: monitor + // inherited heap). + for (k, v) in extra { + drop(monitor::wipe_pair(k, v)); + } + monitor::run(service_pid, secrets_dir_fd, written); + } + _ => {} + } + // Parent only from here (the fork-child diverged). Restore the + // pre-guard signal mask so the service starts with a clean signal + // state, then exec exactly as before. + sig_guard.restore_mask(); + let mut cmd = std::process::Command::new(program); + cmd.args(&argv[1..]).envs(extra); + let err = cmd.exec(); // only returns on failure + // execvp failed: the process image was NOT replaced, so any + // plaintext secret files written above would otherwise persist + // (the macOS ramdisk survives process death). Remove them before + // returning the error (audit 2026-08-02 A9). The monitor watching + // this process fires on the imminent exit and cleans again — + // unlinkat of already-removed names is a no-op. + if let Some(dir_fd) = &secrets_dir_fd { + cleanup_written(dir_fd, &written); + } log(&format!("exec {program}: {err}")); Err(format!("exec {program}: {err}")) } @@ -1120,69 +511,6 @@ pub fn exec(config_path: &Path, argv: &[String], verify_keys: bool) -> Result<() mod tests { use super::*; - #[test] - fn mount_guard_parses_mount_output() { - let out = "/dev/disk3 on / (apfs, local)\n/dev/disk7 on /private/var/run/postmaster (hfs, local, nodev, nosuid, noexec)\n"; - assert!(mount_lists_hfs( - out, - Path::new("/private/var/run/postmaster") - )); - assert!(!mount_lists_hfs(out, Path::new("/private/var/run/other"))); - assert!(!mount_lists_hfs("/dev/disk3 on / (apfs)\n", Path::new("/"))); - } - - #[test] - fn mount_device_for_extracts_device() { - let out = "/dev/disk3 on / (apfs, local)\n/dev/disk7 on /private/var/run/postmaster (hfs, local, nodev, nosuid, noexec)\n"; - assert_eq!( - mount_device_for(out, Path::new("/private/var/run/postmaster")), - Some("/dev/disk7".to_string()) - ); - assert_eq!(mount_device_for(out, Path::new("/nonexistent")), None); - } - - #[test] - fn device_is_ram_backed_detects_ram_prefix() { - let hdiutil = "\ -framework : 683.100.3 -driver : 683.100.3 -================================================ -image-path : ram://1024 -shadow-path : -icon-path : /System/Library/PrivateFrameworks/DiskImages.framework/Resources/CDiskImage.icns -image-type : read/write -system-image : false -blockcount : 1024 -blocksize : 512 -writeable : TRUE -autodiskmount : false -removable : TRUE ---\nframework name : DiskImages -/dev/disk8 -"; - assert!(device_is_ram_backed("/dev/disk8", hdiutil)); - - let hdiutil_file = "\ -================================================ -image-path : /Users/user/image.dmg -shadow-path : -image-type : read/write -/dev/disk9 -"; - assert!(!device_is_ram_backed("/dev/disk9", hdiutil_file)); - - // Device not in hdiutil output at all - assert!(!device_is_ram_backed("/dev/disk99", hdiutil)); - } - - #[test] - fn env_pairs_rejects_interior_nul() { - let ok = vec![("A".to_string(), Zeroizing::new(b"x\ny".to_vec()))]; - assert_eq!(env_pairs(&ok).unwrap().len(), 1); - let bad = vec![("A".to_string(), Zeroizing::new(b"x\0y".to_vec()))]; - assert!(env_pairs(&bad).unwrap_err().contains("NUL")); - } - #[test] fn validate_credential_keys_accepts_identifiers() { assert!(validate_credential_keys(&["DB_URL".to_string()]).is_ok()); @@ -1199,43 +527,15 @@ image-type : read/write } } - fn tdir(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("pm-launcher-{tag}-{}", std::process::id())); - let _ = fs::remove_dir_all(&d); - fs::create_dir_all(&d).unwrap(); - d - } - - #[test] - fn write_secret_file_refuses_symlink() { - let dir = tdir("symlink"); - // A persistent-disk target that must never receive the plaintext. - let real_target = dir.join("real-target"); - fs::write(&real_target, b"untouched").unwrap(); - // Attacker-planted symlink at the path write_secret_file will target. - let link_path = dir.join("KEY"); - std::os::unix::fs::symlink(&real_target, &link_path).unwrap(); - - let result = write_secret_file(&dir, "KEY", b"attacker-controlled-plaintext"); - - assert!( - result.is_err(), - "write_secret_file must refuse to follow a symlink at the target path" - ); - assert_eq!( - fs::read(&real_target).unwrap(), - b"untouched", - "the symlink's target file must not be written to" - ); - } - #[test] fn exec_rejects_empty_argv_instead_of_panicking() { use std::os::unix::fs::PermissionsExt; // A config that resolves cleanly in "env" mode (no ramdisk/creds // machinery involved) so the empty-argv guard is what's exercised, // not an earlier, unrelated failure. - let d = tdir("emptyargv"); + let d = std::env::temp_dir().join(format!("pm-launcher-emptyargv-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); let keys_path = d.join(".env.keys"); fs::write( &keys_path, @@ -1260,235 +560,4 @@ image-type : read/write let err = exec(&cfg_path, &[], false).unwrap_err(); assert!(err.contains("empty argv"), "{err}"); } - - #[test] - fn prune_stale_removes_unconfigured_files() { - let dir = tdir("prune"); - // Simulate a previous launch that wrote KEY_A and KEY_B. - fs::write(dir.join("KEY_A"), b"old-a").unwrap(); - fs::write(dir.join("KEY_B"), b"old-b").unwrap(); - - // New config only keeps KEY_A; KEY_B should be pruned. - prune_stale(&dir, &["KEY_A".to_string()]).unwrap(); - - assert!(dir.join("KEY_A").exists(), "KEY_A should survive prune"); - assert!(!dir.join("KEY_B").exists(), "KEY_B should be pruned"); - } - - #[test] - fn prune_stale_skips_non_files_and_missing_dir() { - let dir = tdir("prune-nonfile"); - // A subdirectory should be skipped, not unlinked. - fs::create_dir(dir.join("subdir")).unwrap(); - // A file that is kept should survive. - fs::write(dir.join("KEEP_ME"), b"data").unwrap(); - - prune_stale(&dir, &["KEEP_ME".to_string()]).unwrap(); - assert!(dir.join("KEEP_ME").exists()); - assert!(dir.join("subdir").is_dir(), "subdir should not be removed"); - - // Non-existent directory: no error, nothing to prune. - let ghost = dir.join("does-not-exist"); - assert!(prune_stale(&ghost, &["X".to_string()]).is_ok()); - } - - #[test] - fn cleanup_written_unlinks_files() { - let dir = tdir("cleanup"); - let f1 = dir.join("A"); - let f2 = dir.join("B"); - fs::write(&f1, b"secret-a").unwrap(); - fs::write(&f2, b"secret-b").unwrap(); - - cleanup_written(&[f1.clone(), f2.clone()]); - - assert!(!f1.exists(), "cleanup should unlink f1"); - assert!(!f2.exists(), "cleanup should unlink f2"); - } - - #[test] - fn cleanup_removes_all_files_from_secrets_dir() { - let dir = tdir("cleanup-subcmd"); - fs::write(dir.join("KEY_A"), b"secret-a").unwrap(); - fs::write(dir.join("KEY_B"), b"secret-b").unwrap(); - fs::create_dir(dir.join("subdir")).unwrap(); - - let cfg_path = dir.join("exec.json"); - fs::write( - &cfg_path, - format!( - r#"{{"mode":"files","keys":["environment"],"env_files":["/nonexistent"],"secrets_dir":"{}"}}"#, - dir.display() - ), - ) - .unwrap(); - - cleanup(&cfg_path).unwrap(); - - assert!(!dir.join("KEY_A").exists(), "KEY_A should be removed"); - assert!(!dir.join("KEY_B").exists(), "KEY_B should be removed"); - assert!(dir.join("subdir").exists(), "subdir should be preserved"); - } - - #[test] - fn cleanup_nonexistent_dir_is_ok() { - let dir = tdir("cleanup-missing"); - let cfg_path = dir.join("exec.json"); - fs::write( - &cfg_path, - format!( - r#"{{"mode":"files","keys":["environment"],"env_files":["/nonexistent"],"secrets_dir":"{}/no-such-dir"}}"#, - dir.display() - ), - ) - .unwrap(); - - assert!(cleanup(&cfg_path).is_ok()); - } - - #[test] - fn load_ring_credential_is_skippable() { - // credential source with no CREDENTIALS_DIRECTORY set should fall - // through to the next source. Here the next source is "environment", - // which is terminal and will fail closed (no DOTENV_PRIVATE_KEY* - // vars in the test runner). The error must be from the environment - // source, not from the credential source. - // - // SAFETY: set_var/remove_var are unsafe on edition 2024 (not - // thread-safe). This test does not spawn threads. - unsafe { - std::env::remove_var("CREDENTIALS_DIRECTORY"); - } - let specs = vec![ - KeySourceSpec::Credential("nonexistent".to_string()), - KeySourceSpec::Environment, - ]; - let err = load_ring(&specs, &[]).unwrap_err(); - // The error should come from the environment source (terminal), - // proving credential fell through rather than erroring. - assert!( - err.contains("environment:") || err.contains("no usable key source"), - "credential should be skippable; got: {err}" - ); - } - - #[test] - fn load_ring_credential_rejects_symlink() { - // Audit finding #2: a symlink planted at - // $CREDENTIALS_DIRECTORY/ must be rejected, not followed. - use std::os::unix::fs::PermissionsExt; - let d = tdir("cred-symlink"); - let real = d.join("real-keys"); - fs::write(&real, format!("DOTENV_PRIVATE_KEY={}\n", "11".repeat(32))).unwrap(); - fs::set_permissions(&real, fs::Permissions::from_mode(0o600)).unwrap(); - let cred_dir = d.join("creds"); - fs::create_dir_all(&cred_dir).unwrap(); - let link = cred_dir.join("dotenv-private-key"); - std::os::unix::fs::symlink(&real, &link).unwrap(); - - // SAFETY: set_var/remove_var are unsafe on edition 2024 (not - // thread-safe). This test does not spawn threads. - unsafe { - std::env::set_var("CREDENTIALS_DIRECTORY", &cred_dir); - } - let specs = vec![KeySourceSpec::Credential("dotenv-private-key".to_string())]; - let err = load_ring(&specs, &[]).unwrap_err(); - unsafe { - std::env::remove_var("CREDENTIALS_DIRECTORY"); - } - assert!( - err.contains("symlink"), - "credential symlink should be rejected; got: {err}" - ); - } - - #[test] - fn load_ring_credential_rejects_non_file() { - // A directory as a credential source name should be rejected, - // not silently skipped or treated as a file. - let d = tdir("cred-dir"); - let cred_dir = d.join("creds"); - fs::create_dir_all(&cred_dir).unwrap(); - let sub = cred_dir.join("subdir"); - fs::create_dir_all(&sub).unwrap(); - - // SAFETY: set_var/remove_var are unsafe on edition 2024 (not - // thread-safe). This test does not spawn threads. - unsafe { - std::env::set_var("CREDENTIALS_DIRECTORY", &cred_dir); - } - let specs = vec![KeySourceSpec::Credential("subdir".to_string())]; - let err = load_ring(&specs, &[]).unwrap_err(); - unsafe { - std::env::remove_var("CREDENTIALS_DIRECTORY"); - } - assert!( - err.contains("not a regular file"), - "credential directory should be rejected; got: {err}" - ); - } - - #[test] - fn load_ring_file_is_terminal() { - // file source with a nonexistent path should return an error - // immediately, NOT fall through to the next source. We verify this - // by putting a valid "environment" source after the bad file source: - // if file were skippable, load_ring would try environment and fail - // with "environment:" — instead it must fail with the file path. - let ghost_path = tdir("terminal-file").join("ghost.keys"); - let specs = vec![ - KeySourceSpec::File(ghost_path.clone()), - KeySourceSpec::Environment, - ]; - let err = load_ring(&specs, &[]).unwrap_err(); - assert!( - err.contains("ghost.keys"), - "file source should be terminal and error with the path; got: {err}" - ); - } - - #[test] - fn load_ring_file_rejects_nix_store() { - // file source under /nix/store must return immediately with an - // error, not fall through to a subsequent source. - let specs = vec![ - KeySourceSpec::File(std::path::PathBuf::from("/nix/store/abc/.env.keys")), - KeySourceSpec::Environment, - ]; - let err = load_ring(&specs, &[]).unwrap_err(); - assert!( - err.contains("Nix store"), - "file source under /nix/store should be terminal and reject; got: {err}" - ); - } - - #[test] - fn load_ring_environment_is_terminal() { - // environment source with no DOTENV_PRIVATE_KEY* vars should return - // an error immediately, not fall through. Since it's the last source, - // we verify the error is from from_env, not from the "no usable key - // source" fallback message. - // - // SAFETY: set_var/remove_var are unsafe on edition 2024 (not - // thread-safe). This test does not spawn threads. - let var_name = "DOTENV_PRIVATE_KEY_TESTTERMINAL"; - unsafe { - std::env::remove_var(var_name); - } - let specs = vec![ - KeySourceSpec::Credential("skipped".to_string()), - KeySourceSpec::Environment, - ]; - // Remove CREDENTIALS_DIRECTORY so credential is skippable. - unsafe { - std::env::remove_var("CREDENTIALS_DIRECTORY"); - } - let err = load_ring(&specs, &[]).unwrap_err(); - // The error must be from environment (terminal), not "no usable key - // source" (which would mean environment also fell through). - assert!( - !err.contains("no usable key source"), - "environment should be terminal, not fall through; got: {err}" - ); - } } diff --git a/src/lib.rs b/src/lib.rs index b5932f2..d2310c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,16 +6,39 @@ //! //! Security and fail-closed semantics are preserved exactly. -#![allow(missing_docs, missing_debug_implementations)] +// `multiple_crate_versions` is warn-level in Cargo.toml; allowed here for +// exactly one uncontrollable case: syn 2 + syn 3 coexist only because +// wasm-bindgen (target-gated to wasm32-unknown-unknown inside the ecies +// dependency, never compiled for postmaster's real targets) pins syn 2 +// while serde_derive uses syn 3. cargo-deny `[bans].multiple-versions` +// (warn, with syn skipped) keeps watch for the tree converging and for any +// NEW duplicates — remove this allow when syn 2 leaves the tree. +#![allow(clippy::multiple_crate_versions)] pub mod adapter; -pub mod bundle; -pub mod config; +pub mod cli; +pub mod exec_config; pub mod keys; pub mod launcher; +pub(crate) mod monitor; +pub mod ramdisk; +pub mod secret_files; pub mod server; +#[cfg(test)] +pub(crate) mod test_util; -pub use config::{Invocation, parse_args, parse_args_from}; +/// The bundle adapter, re-exported so `crate::bundle` keeps resolving. +pub use adapter::bundle; +/// The CLI module under its former name, so `crate::config` keeps resolving. +pub use cli as config; +pub use cli::{Invocation, parse_args, parse_args_from}; +/// Key provisioning under its former name, so `crate::key_provisioning` +/// keeps resolving. +pub use keys::provisioning as key_provisioning; +pub use keys::provisioning::{cleanup, setup}; +/// Key-source selection under its former name, so `crate::key_source` +/// keeps resolving. +pub use keys::source as key_source; pub use server::{fetch, harden_process, run}; /// Re-exported for binary use. The real main is here so tests can call into it. @@ -41,8 +64,8 @@ pub fn main() -> std::process::ExitCode { unattended, force, keys_dir, - }) => launcher::setup(&config, recheck, unattended, force, keys_dir), - Ok(Invocation::Cleanup { config }) => launcher::cleanup(&config), + }) => setup(&config, recheck, unattended, force, keys_dir), + Ok(Invocation::Cleanup { config }) => cleanup(&config), #[allow(clippy::print_stdout, clippy::print_stderr)] Ok(Invocation::Help(sub)) => { let text = match sub.as_deref() { diff --git a/src/main.rs b/src/main.rs index 36379d6..54a89ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,10 @@ //! they can be unit-tested. See `lib.rs` and the submodule docs for the //! full security posture, fail-closed design, and invariants. +// See src/lib.rs for why this allow exists (wasm-bindgen's wasm32-gated +// syn 2 vs serde_derive's syn 3; uncontrollable, deny.toml keeps watch). +#![allow(clippy::multiple_crate_versions)] + fn main() -> std::process::ExitCode { postmaster::main() } diff --git a/src/monitor.rs b/src/monitor.rs new file mode 100644 index 0000000..740a4a9 --- /dev/null +++ b/src/monitor.rs @@ -0,0 +1,758 @@ +//! Sibling launch monitor — a per-service fork-child that owns stop +//! semantics and plaintext cleanup on every service-death path +//! (sibling-monitor launch supervision design, 2026-08-05). +//! +//! The launcher forks the monitor after validation, plaintext writes, and +//! zeroization, and immediately before `execvp`: the parent restores its +//! pre-guard signal mask and execs the service (MainPID unchanged), and +//! the fork-child becomes the monitor. The monitor carries only the +//! pinned secrets-dir fd (inherited across fork; `CLOEXEC` does not fire +//! because the monitor never execs) and the list of written entry names +//! — in env mode both are empty and cleanup is a no-op, but the monitor +//! still runs (uniform semantics: it forwards stops regardless). +//! +//! Signal discipline: the four termination signals ({TERM, INT, HUP, +//! QUIT}) stay *blocked for the monitor's whole life* — the mask is +//! inherited from the launcher's `TerminationGuard` across fork and +//! re-asserted at monitor start — and are consumed synchronously via the +//! `sigpending`/`sigwait` family. No signal handlers exist anywhere in +//! the process (the sigaction flag-handler was retired with this design). +//! SIGPIPE is additionally blocked so a stderr write to a closed pipe +//! cannot kill the monitor mid-supervision. +//! +//! Loop semantics (one tick = `POLL_INTERVAL`): +//! 1. A pending stop signal is dequeued → forward it to the service → +//! wait up to `GRACE` (250 ms constant — a single global security +//! policy, deliberately not configurable) for the service to die → +//! if still alive, `SIGKILL` → wait for death (unbounded: `SIGKILL` +//! cannot be blocked or ignored; only a D-state hang can delay it, and +//! abandoning cleanup is worse) → `cleanup_written` → exit. +//! 2. Otherwise, if the service is dead (liveness watch: pidfd + `poll` +//! on Linux, kqueue `EVFILT_PROC`/`NOTE_EXIT` on macOS, `kill(pid, 0)` +//! only as fallback) → `cleanup_written` → exit. This covers natural +//! exit, `kill -9`, and crashes — the net-new cleanup coverage. +//! 3. Otherwise, sleep one tick. Liveness/stop latency adds to cleanup +//! latency, never to the grace cap. +//! +//! Fork-child discipline (the soundness contract for `launcher.rs`'s +//! `fork`): the fork parent may be multithreaded (it is under +//! `cargo test`), so between fork and exit the monitor performs no +//! allocation and takes no stdio locks — only the async-signal-safe +//! syscall set: signal mask/wait ops, `pidfd_open`/`poll`/`kevent`, +//! `kill`/`pidfd_send_signal`, `unlinkat`, `nanosleep`, raw `write(2)` +//! for diagnostics, and `_exit`/`exit_group`. All inputs (`names`, the +//! dir fd) are allocated pre-fork and moved in. Exit never runs +//! destructors, atexit handlers, or stdio flushes (inherited buffers +//! must not flush twice). + +use std::os::fd::{AsFd, OwnedFd}; +use std::time::{Duration, Instant}; + +use rustix::io::Errno; +use rustix::process::{Pid, Signal, kill_process, test_kill_process}; +use zeroize::Zeroize; + +#[cfg(target_os = "linux")] +use rustix::event::{PollFd, PollFlags, poll}; +#[cfg(target_os = "linux")] +use rustix::process::{PidfdFlags, pidfd_open, pidfd_send_signal}; +#[cfg(target_os = "linux")] +use rustix::runtime::{self, KernelSigSet}; +#[cfg(target_os = "macos")] +use std::os::fd::{AsRawFd, FromRawFd}; + +use crate::secret_files::cleanup_written; + +/// Scrub one inherited env pair in place and return the wiped buffers +/// (threat model: monitor inherited heap). The fork-child monitor +/// inherits the launcher's `extra` env array — plaintext values in env +/// mode and in files-mode passthrough — and `free()` alone would leave +/// those bytes in the monitor's mapped pages for its whole life (the +/// monitor never allocates again, so they would never even be reused). +/// `into_vec` takes ownership of the existing `OsString` buffer with no +/// copy, and `zeroize` wipes it in place before the buffers are freed. +pub(crate) fn wipe_pair(k: std::ffi::OsString, v: std::ffi::OsString) -> (Vec, Vec) { + use std::os::unix::ffi::OsStringExt; + let mut kb = k.into_vec(); + let mut vb = v.into_vec(); + kb.zeroize(); + vb.zeroize(); + (kb, vb) +} + +/// The termination signals the monitor consumes synchronously and +/// forwards to the service. Also blocked across the launcher's +/// plaintext-write window (`TerminationGuard`) and inherited by the +/// monitor across fork. +pub(crate) const STOP_SIGNALS: [Signal; 4] = [Signal::TERM, Signal::INT, Signal::HUP, Signal::QUIT]; + +/// Grace between forwarding a stop signal and `SIGKILL` escalation: +/// 250 ms, a single global security policy (author ruling 2026-08-05 — +/// per-service erosion is unacceptable; the per-unit knob is the service +/// manager's own stop timeout). Injectable in tests via `run_inner`. +pub(crate) const GRACE: Duration = Duration::from_millis(250); + +/// Liveness/poll tick. Short is fine: this latency adds to cleanup +/// latency, not to the grace cap. +pub(crate) const POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// Zero timeout for non-blocking `poll(2)` liveness checks on the pidfd. +#[cfg(target_os = "linux")] +const ZERO_TIMESPEC: rustix::event::Timespec = rustix::event::Timespec { + tv_sec: 0, + tv_nsec: 0, +}; + +/// Monitor entry point with production timing. Never returns: the +/// monitor exits the process (via `_exit`/`exit_group`) once the service +/// is dead and the plaintext is cleaned. `dir_fd`/`names` are the pinned +/// secrets-dir fd and written entry names from the launcher; both are +/// absent in env mode (cleanup is then a no-op, but the monitor still +/// runs — uniform stop semantics). +pub(crate) fn run(service_pid: Pid, dir_fd: Option, names: Vec) -> ! { + run_inner(service_pid, dir_fd, names, GRACE, POLL_INTERVAL) +} + +/// The monitor loop, parameterized for deterministic tests. +fn run_inner( + service_pid: Pid, + dir_fd: Option, + names: Vec, + grace: Duration, + poll_interval: Duration, +) -> ! { + block_monitor_signals(); + let set = stop_sigset(); + let watch = Watch::new(service_pid); + loop { + if let Some(sig) = take_pending_stop(&set) { + // Stop semantics (diverges): forward, bounded grace, SIGKILL + // escalation, then clean and exit. + signal_service(&watch, service_pid, sig); + if !wait_for_death(&watch, service_pid, grace, poll_interval) { + monitor_log("postmaster monitor: grace expired; escalating to SIGKILL"); + signal_service(&watch, service_pid, Signal::KILL); + while !watch.dead(service_pid) { + std::thread::sleep(poll_interval); + } + } + cleanup_and_exit(dir_fd.as_ref(), &names); + } + if watch.dead(service_pid) { + // Service death from any cause: natural exit, kill -9, crash. + cleanup_and_exit(dir_fd.as_ref(), &names); + } + std::thread::sleep(poll_interval); + } +} + +/// Wait up to `grace` for the service to die. Returns true when death is +/// confirmed within the grace window. +fn wait_for_death(watch: &Watch, pid: Pid, grace: Duration, poll_interval: Duration) -> bool { + let deadline = Instant::now() + grace; + loop { + if watch.dead(pid) { + return true; + } + let now = Instant::now(); + if now >= deadline { + return false; + } + std::thread::sleep(poll_interval.min(deadline - now)); + } +} + +/// Unlink the written plaintext files (fd-relative, best-effort — an +/// already-vanished entry is ignored) and exit the monitor. Never +/// returns. +fn cleanup_and_exit(dir_fd: Option<&Fd>, names: &[String]) -> ! { + if let Some(fd) = dir_fd { + cleanup_written(fd, names); + } else if !names.is_empty() { + // Unreachable from the launcher (names are only tracked in fd + // modes); logged so a future caller regression is visible. + monitor_log("postmaster monitor: entry names without a directory fd; cannot clean"); + } + exit_process(0) +} + +/// Send `sig` to the service. Delivery errors (e.g. `ESRCH` because the +/// service died first) are ignored — the subsequent death wait settles +/// the outcome either way. +#[cfg(target_os = "linux")] +fn signal_service(watch: &Watch, pid: Pid, sig: Signal) { + // With a pidfd, signal through it: pidfd_send_signal(2) pins the + // process and cannot hit a recycled PID — the same PID-reuse + // rationale that makes pidfd the liveness choice. + if let Watch::Pidfd(fd) = watch { + let _ = pidfd_send_signal(fd, sig); + } else { + let _ = kill_process(pid, sig); + } +} + +/// Send `sig` to the service (see the Linux leg). Darwin has no pidfd, +/// so forwarding is plain kill(2); the bounded PID-reuse exposure on the +/// macOS stop path is documented in the threat model. +#[cfg(target_os = "macos")] +fn signal_service(_watch: &Watch, pid: Pid, sig: Signal) { + let _ = kill_process(pid, sig); +} + +/// Exit the monitor without running destructors, atexit handlers, or +/// stdio flushes — mandatory in a fork-child (inherited buffers and +/// handlers must not run twice; see module docs). +#[cfg(target_os = "linux")] +fn exit_process(code: i32) -> ! { + // exit_group(2) ≡ _exit(2): a safe rustix wrapper, no libc FFI. + runtime::exit_group(code) +} + +/// Exit the monitor (see the Linux leg). +#[cfg(target_os = "macos")] +fn exit_process(code: i32) -> ! { + // SAFETY: _exit(2) is async-signal-safe and callable from any + // context; it runs no handlers and flushes nothing — exactly the + // fork-child exit semantics required here. + unsafe { libc::_exit(code) } +} + +/// Best-effort diagnostic to stderr that stays inside the fork-child +/// discipline: a raw write(2) — no allocation, no stdio locks (the fork +/// parent may be multithreaded, e.g. under `cargo test`). SIGPIPE is +/// blocked for the monitor's life, so a closed pipe cannot kill us here. +fn monitor_log(msg: &str) { + // SAFETY: fd 2 is the process's stderr; if it has been closed the + // write simply fails with EBADF. The borrowed fd is not stored and + // does not outlive the call; nothing is closed. + let stderr = unsafe { std::os::fd::BorrowedFd::borrow_raw(2) }; + let _ = rustix::io::write(stderr, msg.as_bytes()); + let _ = rustix::io::write(stderr, b"\n"); +} + +/// The service liveness watch. Event sources (pidfd `POLLIN`, kqueue +/// `NOTE_EXIT`) fire at process exit — zombie- and PID-reuse-proof, +/// which is why they are the choice and `kill(pid, 0)` polling is only +/// the fallback (zombie-blind, PID-reuse-prone). +#[cfg(target_os = "linux")] +enum Watch { + /// pidfd(2) opened at monitor start; the fd pins the process. + Pidfd(OwnedFd), + /// kill(pid, 0) fallback for kernels without pidfd_open (< 5.3). + Poll, + /// The service was already dead when the monitor started. + Dead, +} + +#[cfg(target_os = "linux")] +impl Watch { + fn new(pid: Pid) -> Self { + match pidfd_open(pid, PidfdFlags::empty()) { + Ok(fd) => Self::Pidfd(fd), + // ESRCH is race-free "definitely gone" — clean up at once. + Err(Errno::SRCH) => Self::Dead, + // ENOSYS (old kernel), EMFILE, …: degrade to polling. + Err(_) => { + monitor_log( + "postmaster monitor: pidfd_open failed; falling back to kill(pid, 0) polling", + ); + Self::Poll + } + } + } + + fn dead(&self, pid: Pid) -> bool { + match self { + Self::Dead => true, + Self::Poll => matches!(test_kill_process(pid), Err(Errno::SRCH)), + Self::Pidfd(fd) => { + let mut fds = [PollFd::new(fd, PollFlags::IN)]; + match poll(&mut fds, Some(&ZERO_TIMESPEC)) { + // POLLIN: the process has terminated (fires at exit, + // not at reap — zombie-proof). POLLERR is treated as + // dead too: a pidfd that errors cannot supervise. + Ok(_) => fds[0].revents().intersects(PollFlags::IN | PollFlags::ERR), + // A failed poll means "unknown", not "dead": keep + // watching rather than abandon supervision. + Err(_) => false, + } + } + } + } +} + +/// The service liveness watch (see the Linux leg). +#[cfg(target_os = "macos")] +enum Watch { + /// kqueue with `EVFILT_PROC`/`NOTE_EXIT` registered for the service. + Kqueue(OwnedFd), + /// kill(pid, 0) fallback (kqueue creation failed — fd exhaustion). + Poll, + /// The service was already dead when the monitor started. + Dead, +} + +#[cfg(target_os = "macos")] +impl Watch { + fn new(pid: Pid) -> Self { + // ESRCH is race-free "definitely gone" — clean up at once. + if matches!(test_kill_process(pid), Err(Errno::SRCH)) { + return Self::Dead; + } + // SAFETY: kqueue(2) has no preconditions and returns a fresh + // queue fd on success. The kevent registration passes valid + // stack pointers and a well-formed changelist built from + // constants; a NULL timeout with an empty eventlist cannot block. + unsafe { + let kq = libc::kqueue(); + if kq == -1 { + monitor_log( + "postmaster monitor: kqueue failed; falling back to kill(pid, 0) polling", + ); + return Self::Poll; + } + let change = libc::kevent { + ident: pid.as_raw_pid() as usize, + filter: libc::EVFILT_PROC, + flags: libc::EV_ADD, + fflags: libc::NOTE_EXIT, + data: 0, + udata: std::ptr::null_mut(), + }; + if libc::kevent(kq, &change, 1, std::ptr::null_mut(), 0, std::ptr::null()) == -1 { + // With a changelist-only call, a knote error comes back + // as the kevent errno. ESRCH means the pid lookup failed + // because the service already exited (xnu drops exited + // procs from the pid table before they are reaped) — + // race-free "definitely gone", same as pidfd_open's ESRCH + // on Linux, so clean up at once rather than degrading to + // zombie-blind polling. + let dead = std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH); + libc::close(kq); + if dead { + return Self::Dead; + } + monitor_log( + "postmaster monitor: kevent registration failed; falling back to kill(pid, 0) polling", + ); + return Self::Poll; + } + // SAFETY: kq is a fresh, valid fd owned by this process; + // wrapping it in OwnedFd closes it exactly once at drop. + Self::Kqueue(OwnedFd::from_raw_fd(kq)) + } + } + + fn dead(&self, pid: Pid) -> bool { + match self { + Self::Dead => true, + Self::Poll => matches!(test_kill_process(pid), Err(Errno::SRCH)), + Self::Kqueue(kq) => { + // SAFETY: `ev`/`ts` are valid stack locals that outlive + // the call; a zero timeout makes kevent(2) a non-blocking + // queue drain; the queue fd is valid (owned). + unsafe { + let mut ev: libc::kevent = std::mem::zeroed(); + let ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + if libc::kevent(kq.as_raw_fd(), std::ptr::null(), 0, &mut ev, 1, &ts) > 0 { + // The queue holds only the one NOTE_EXIT knote. + return true; + } + } + // Defense-in-depth net for any exit the knote misses + // (xnu attach subtleties): ESRCH is race-free "definitely + // gone". The registration-time case of this race returns + // Watch::Dead above instead. + matches!(test_kill_process(pid), Err(Errno::SRCH)) + } + } + } +} + +/// Block the stop signals plus `SIGPIPE` for the monitor's lifetime. +/// The four stop signals are normally already blocked by the launcher's +/// `TerminationGuard` and inherited across fork (blocking again is a +/// no-op); re-asserting keeps the monitor correct when the caller did +/// not pre-block (unit tests). `SIGPIPE` is monitor-local so the raw +/// stderr write in `monitor_log` cannot kill the monitor via a closed +/// pipe. A failure is logged and supervision continues: the inherited +/// mask is the common case, and failing to block cannot strand +/// plaintext by itself. +#[cfg(target_os = "linux")] +fn block_monitor_signals() { + let mut set = KernelSigSet::empty(); + for sig in STOP_SIGNALS { + set.insert(sig); + } + set.insert(Signal::PIPE); + // SAFETY: single-threaded fork-child; the set holds only standard + // signals — none reserved by a libc in the process (glibc reserves + // 32/33, untouched). Blocking these signals for life breaks no other + // code's assumptions: the monitor consumes the stop set synchronously + // and never installs handlers. + if unsafe { runtime::kernel_sigprocmask(runtime::How::BLOCK, Some(&set)) }.is_err() { + monitor_log("postmaster monitor: failed to block termination signals"); + } +} + +/// Block the stop signals plus `SIGPIPE` (see the Linux leg). +#[cfg(target_os = "macos")] +fn block_monitor_signals() { + // SAFETY: zeroed sigset_t is a valid empty set representation; all + // pointers are valid stack locals that outlive the calls; the pthread + // form is correct in single- and multi-threaded processes. + unsafe { + let mut set: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut set); + for sig in STOP_SIGNALS { + libc::sigaddset(&mut set, sig.as_raw()); + } + libc::sigaddset(&mut set, libc::SIGPIPE); + if libc::pthread_sigmask(libc::SIG_BLOCK, &set, std::ptr::null_mut()) != 0 { + monitor_log("postmaster monitor: failed to block termination signals"); + } + } +} + +/// The set of signals consumed synchronously by the loop. +#[cfg(target_os = "linux")] +fn stop_sigset() -> KernelSigSet { + let mut set = KernelSigSet::empty(); + for sig in STOP_SIGNALS { + set.insert(sig); + } + set +} + +/// The set of signals consumed synchronously by the loop. +#[cfg(target_os = "macos")] +fn stop_sigset() -> libc::sigset_t { + // SAFETY: zeroed sigset_t is a valid empty set representation; + // sigemptyset/sigaddset operate on a valid stack local. + unsafe { + let mut set: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut set); + for sig in STOP_SIGNALS { + libc::sigaddset(&mut set, sig.as_raw()); + } + set + } +} + +/// Dequeue a pending stop signal without blocking, if one is pending. +/// The signals are blocked for life, so they pend until consumed here — +/// synchronous consumption, no handlers anywhere. (Darwin has no +/// `sigtimedwait`, so both platforms use the portable +/// sigpending-then-sigwait form on the poll tick; the bounded stop +/// latency — one tick — is the latency the design explicitly budgets.) +#[cfg(target_os = "linux")] +fn take_pending_stop(set: &KernelSigSet) -> Option { + if !STOP_SIGNALS + .iter() + .any(|s| runtime::kernel_sigpending().contains(*s)) + { + return None; + } + // SAFETY: a signal from the set IS pending (checked above), so + // sigwait dequeues and returns immediately; the set holds only + // standard non-reserved signals; single-threaded here. + unsafe { runtime::kernel_sigwait(set) }.ok() +} + +/// Dequeue a pending stop signal without blocking (see the Linux leg). +#[cfg(target_os = "macos")] +fn take_pending_stop(set: &libc::sigset_t) -> Option { + // SAFETY: zeroed sigset_t is valid and sigpending fills it; sigwait + // is called only when a set member is pending, so it returns + // immediately; all pointers are valid stack locals. + unsafe { + let mut pending: libc::sigset_t = std::mem::zeroed(); + if libc::sigpending(&mut pending) != 0 { + return None; + } + if !STOP_SIGNALS + .iter() + .any(|s| libc::sigismember(&pending, s.as_raw()) == 1) + { + return None; + } + let mut got: libc::c_int = 0; + if libc::sigwait(set, &mut got) != 0 { + return None; + } + Signal::from_named_raw(got) + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +compile_error!("postmaster's sibling monitor supports Linux and macOS only"); + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::process::ExitStatusExt; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use rustix::process::{WaitOptions, waitpid}; + + use crate::launcher::TerminationGuard; + use crate::secret_files::open_secrets_dir; + + fn tdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pm-monitor-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + d + } + + /// Fork a monitor watching `service_pid`, mirroring the production + /// launcher: the calling thread blocks the stop signals first (the + /// child is born with them blocked — no ready-race between fork and + /// the monitor's own block), the fork-child runs the REAL monitor + /// loop with test-scaled timing, the parent restores its mask and + /// returns the monitor's pid. + fn fork_monitor( + service_pid: Pid, + dir: Option<&Path>, + names: Vec, + grace: Duration, + poll_interval: Duration, + ) -> Pid { + let guard = TerminationGuard::block(); + let dir_fd = dir.map(|d| open_secrets_dir(d).unwrap()); + // SAFETY: fork(2) in the test process. The child executes only + // run_inner — documented async-signal-safe discipline: no + // allocation, no stdio locks, raw syscalls only — and then exits + // via _exit/exit_group, so the multithreaded test harness's + // malloc locks and output buffers are never touched in the child. + match unsafe { libc::fork() } { + -1 => panic!("fork: {}", std::io::Error::last_os_error()), + 0 => { + // Never returns (`-> !`), so the guard is never dropped + // and the mask stays blocked for the monitor's life + // (run_inner re-asserts it regardless). + run_inner(service_pid, dir_fd, names, grace, poll_interval); + } + child => { + guard.restore_mask(); + Pid::from_raw(child).unwrap() + } + } + } + + /// Reap the monitor, asserting it exits 0 within `timeout`. On + /// timeout the monitor is SIGKILLed and reaped so a regression fails + /// the test instead of hanging the suite. + fn wait_monitor_exit(pid: Pid, timeout: Duration) -> i32 { + let deadline = Instant::now() + timeout; + loop { + match waitpid(Some(pid), WaitOptions::NOHANG).unwrap() { + Some((_, status)) => { + if let Some(sig) = status.terminating_signal() { + panic!("monitor died by signal {sig}"); + } + return status.exit_status().unwrap_or(-1); + } + None => { + if Instant::now() >= deadline { + let _ = kill_process(pid, Signal::KILL); + let _ = waitpid(Some(pid), WaitOptions::empty()); + panic!("monitor did not exit within {timeout:?}"); + } + std::thread::sleep(Duration::from_millis(5)); + } + } + } + } + + fn write_dummies(dir: &Path) -> Vec { + fs::write(dir.join("A"), b"secret-a").unwrap(); + fs::write(dir.join("B"), b"secret-b").unwrap(); + vec!["A".to_string(), "B".to_string()] + } + + /// Reaps a forked/spawned pid on drop (SIGKILL first) so a failed + /// assertion can't leak a stray service process — important for the + /// TERM-ignoring service, which would otherwise outlive the test + /// binary indefinitely. + struct ReapOnDrop(Pid); + + impl Drop for ReapOnDrop { + fn drop(&mut self) { + let _ = kill_process(self.0, Signal::KILL); + let _ = waitpid(Some(self.0), WaitOptions::empty()); + } + } + + /// Fork a "service" that ignores SIGTERM until SIGKILLed: a + /// fork-child that sets TERM to SIG_IGN and pause()s forever. This + /// exercises the grace → SIGKILL escalation deterministically, unlike + /// a shell `trap` — macOS /bin/sh resets TERM for exec'd commands, + /// so the disposition doesn't survive into `sleep` there. + fn fork_term_ignoring_service() -> Pid { + // SAFETY: fork(2) in the test process; the child executes only + // async-signal-safe calls (signal(2), pause(2)) and never returns + // into the test harness — no allocation, no stdio, no locks. + match unsafe { libc::fork() } { + -1 => panic!("fork: {}", std::io::Error::last_os_error()), + 0 => unsafe { + // SAFETY: plain single-threaded signal op on this + // process; SIG_IGN is the documented ignore-disposition + // constant. pause(2) suspends until a caught signal + // arrives; TERM is ignored, so only SIGKILL ends the loop. + libc::signal(libc::SIGTERM, libc::SIG_IGN); + loop { + libc::pause(); + } + }, + child => Pid::from_raw(child).unwrap(), + } + } + + /// Service death NOT mediated by the monitor (kill -9 / crash + /// stand-in) must still trigger cleanup and monitor exit. + #[test] + fn natural_service_death_triggers_cleanup_and_monitor_exit() { + let dir = tdir("natural"); + let names = write_dummies(&dir); + let mut service = Command::new("sleep").arg("30").spawn().unwrap(); + let service_pid = Pid::from_child(&service); + let _reap = ReapOnDrop(service_pid); + let monitor = fork_monitor( + service_pid, + Some(&dir), + names, + Duration::from_millis(50), + Duration::from_millis(10), + ); + + assert!(dir.join("A").exists()); + kill_process(service_pid, Signal::KILL).unwrap(); + let status = service.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGKILL)); + + assert_eq!(wait_monitor_exit(monitor, Duration::from_secs(5)), 0); + assert!( + !dir.join("A").exists() && !dir.join("B").exists(), + "monitor must remove the plaintext files on service death" + ); + let _ = fs::remove_dir_all(&dir); + } + + /// A stop signal to the monitor is forwarded to the service; death + /// follows within the grace (no SIGKILL escalation needed), then + /// cleanup runs and the monitor exits 0. The monitor is born with + /// the signal blocked, so the TERM pends until its first tick — + /// no settle sleep is needed. + #[test] + fn stop_signal_is_forwarded_then_grace_then_cleanup() { + let dir = tdir("forward"); + let names = write_dummies(&dir); + let mut service = Command::new("sleep").arg("30").spawn().unwrap(); + let service_pid = Pid::from_child(&service); + let _reap = ReapOnDrop(service_pid); + let monitor = fork_monitor( + service_pid, + Some(&dir), + names, + Duration::from_millis(500), + Duration::from_millis(10), + ); + + kill_process(monitor, Signal::TERM).unwrap(); + + assert_eq!(wait_monitor_exit(monitor, Duration::from_secs(5)), 0); + let status = service.wait().unwrap(); + assert_eq!( + status.signal(), + Some(libc::SIGTERM), + "the service must die by the forwarded TERM, not by an escalated SIGKILL" + ); + assert!( + !dir.join("A").exists() && !dir.join("B").exists(), + "cleanup must run before the monitor exits" + ); + let _ = fs::remove_dir_all(&dir); + } + + /// A service that ignores SIGTERM is SIGKILLed only after the grace + /// elapses; cleanup still runs and the monitor exits 0. + #[test] + fn term_ignoring_service_is_sigkilled_after_grace() { + let dir = tdir("grace"); + let names = write_dummies(&dir); + let service_pid = fork_term_ignoring_service(); + let _reap = ReapOnDrop(service_pid); + let grace = Duration::from_millis(50); + let monitor = fork_monitor( + service_pid, + Some(&dir), + names, + grace, + Duration::from_millis(10), + ); + + let start = Instant::now(); + kill_process(monitor, Signal::TERM).unwrap(); + + assert_eq!(wait_monitor_exit(monitor, Duration::from_secs(5)), 0); + let Some((_, status)) = waitpid(Some(service_pid), WaitOptions::empty()).unwrap() else { + panic!("blocking waitpid returned no status"); + }; + assert_eq!( + status.terminating_signal(), + Some(libc::SIGKILL), + "a TERM-ignoring service must be SIGKILLed" + ); + assert!( + start.elapsed() >= grace, + "SIGKILL must not come before the grace elapses" + ); + assert!( + !dir.join("A").exists() && !dir.join("B").exists(), + "cleanup must run after the SIGKILL path" + ); + let _ = fs::remove_dir_all(&dir); + } + + /// The inherited-heap scrub (threat model): both buffers of an env + /// pair must come back fully wiped. + #[test] + fn wipe_pair_scrubs_both_buffers() { + let (k, v) = wipe_pair( + std::ffi::OsString::from("SECRET_NAME"), + std::ffi::OsString::from("s3cr3t-value"), + ); + assert!(k.iter().all(|b| *b == 0), "name buffer not wiped"); + assert!(v.iter().all(|b| *b == 0), "value buffer not wiped"); + } + + /// Uniform semantics (design decision 4): the monitor runs even in + /// env mode — no dir fd, an empty name list, cleanup a no-op — and + /// still exits when the service dies. + #[test] + fn env_mode_monitor_with_no_files_exits_on_service_death() { + let mut service = Command::new("sleep").arg("30").spawn().unwrap(); + let service_pid = Pid::from_child(&service); + let _reap = ReapOnDrop(service_pid); + let monitor = fork_monitor( + service_pid, + None, + Vec::new(), + Duration::from_millis(50), + Duration::from_millis(10), + ); + + kill_process(service_pid, Signal::TERM).unwrap(); + let status = service.wait().unwrap(); + assert_eq!(status.signal(), Some(libc::SIGTERM)); + + assert_eq!(wait_monitor_exit(monitor, Duration::from_secs(5)), 0); + } +} diff --git a/src/ramdisk.rs b/src/ramdisk.rs new file mode 100644 index 0000000..568103d --- /dev/null +++ b/src/ramdisk.rs @@ -0,0 +1,207 @@ +//! macOS ramdisk verification — ensures plaintext secret files land only +//! on a RAM-backed HFS disk image owned by the service user. +//! +//! This module implements the `ramdiskGuard` invariant that was previously +//! templated in `modules/darwin.nix`. On Linux the ramdisk check is not +//! used (tmpfs is already RAM-backed by definition). + +use std::fs; +use std::os::unix::fs::MetadataExt; +use std::path::Path; + +/// Pure parser for /sbin/mount output: is `mp` mounted as HFS? +fn mount_lists_hfs(mount_out: &str, mp: &Path) -> bool { + // Strip trailing slashes so a mount point like "/foo/" matches + // "/foo" in mount output (which never includes trailing slashes). + let mp_str = mp.to_string_lossy(); + let mp_trimmed = mp_str.trim_end_matches('/'); + let needle = format!(" on {mp_trimmed} (hfs"); + mount_out.lines().any(|l| l.contains(&needle)) +} + +/// Extract the device path for a mount point from `mount` output. +/// Format: `/dev/disk8 on /path (hfs, ...)` → `/dev/disk8`. +fn mount_device_for(mount_out: &str, mp: &Path) -> Option { + let mp_str = mp.to_string_lossy(); + let mp_trimmed = mp_str.trim_end_matches('/'); + let needle = format!(" on {mp_trimmed} (hfs"); + for line in mount_out.lines() { + if let Some(idx) = line.find(&needle) { + // The device is everything before " on " + let device = &line[..idx].trim(); + if !device.is_empty() { + return Some(device.to_string()); + } + } + } + None +} + +/// Verify that a device is backed by RAM (not a file-backed disk image). +/// Parses `hdiutil info` text output for the device's `image-path` +/// field and checks it starts with `ram://`. +fn device_is_ram_backed(device: &str, hdiutil_out: &str) -> bool { + // hdiutil info output structure (per image entry): + // image-path : ram://1024 + // image-type : read/write + // ... + // /dev/disk8 + // + // The device appears as a line by itself after the image properties. + // We find the device line, then scan backwards for the image-path + // field in the same image entry. + + let lines: Vec<&str> = hdiutil_out.lines().collect(); + let device_line = device.trim(); + for (idx, line) in lines.iter().enumerate() { + if line.trim() == device_line { + // Scan backwards from the device line to find image-path + for prev in lines[..idx].iter().rev() { + let trimmed = prev.trim(); + if trimmed.starts_with("image-path") { + // Extract the value after "image-path : " + if let Some(colon_idx) = trimmed.find(':') { + let value = trimmed[colon_idx + 1..].trim(); + return value.starts_with("ram://"); + } + } + // If we hit another device line or a separator, this + // image entry's properties ended — stop scanning. + if trimmed.starts_with("/dev/") || trimmed.starts_with("====") { + break; + } + } + return false; + } + } + false +} + +/// darwin ramdisk guard (was ramdiskGuard in modules/darwin.nix): plaintext +/// lands only on the postmaster ramdisk, in a directory this user owns. +pub fn verify_ramdisk(mount_point: &Path, secrets_dir: &Path) -> Result<(), String> { + // Trust boundary (audit 2026-08-02 O2): a compromised /sbin/mount or + // hdiutil could report persistent disk as the RAM-backed postmaster + // ramdisk and let plaintext land on persistent storage. Verify both + // helpers before invoking. + crate::keys::verify_external_binary(Path::new("/sbin/mount"))?; + crate::keys::verify_external_binary(Path::new("/usr/bin/hdiutil"))?; + let out = std::process::Command::new("/sbin/mount") + .output() + .map_err(|e| format!("/sbin/mount: {e}"))?; + let text = String::from_utf8_lossy(&out.stdout); + if !mount_lists_hfs(&text, mount_point) { + return Err(format!( + "{}: not the postmaster ramdisk; refusing to write plaintext to persistent disk", + mount_point.display() + )); + } + // Verify the mount is RAM-backed, not a file-backed HFS image. + // Extract the device from mount output, then check hdiutil info + // for that device's image-path starting with ram://. + let device = mount_device_for(&text, mount_point).ok_or_else(|| { + format!( + "{}: could not determine backing device from mount output", + mount_point.display() + ) + })?; + let hdiutil_out = std::process::Command::new("/usr/bin/hdiutil") + .arg("info") + .output() + .map_err(|e| format!("hdiutil info: {e}"))?; + let hdiutil_text = String::from_utf8_lossy(&hdiutil_out.stdout); + if !device_is_ram_backed(&device, &hdiutil_text) { + return Err(format!( + "{}: mount device {device} is not RAM-backed (image-path does not start with ram://); refusing to write plaintext to a file-backed disk image", + mount_point.display() + )); + } + // Use symlink_metadata instead of metadata: if secrets_dir is itself a + // symlink to persistent disk (planted by a prior compromised launch on + // the darwin ramdisk, which persists across restarts), fs::metadata would + // follow the link and inspect the TARGET — a directory owned by the + // service user on persistent disk — and approve it. Reject symlinks + // outright so the ramdisk invariant is not bypassed. + let md = fs::symlink_metadata(secrets_dir).map_err(|e| { + format!( + "{}: {e} (is postmaster-ramdisk healthy?)", + secrets_dir.display() + ) + })?; + if md.file_type().is_symlink() { + return Err(format!( + "{}: secrets_dir is a symlink; refusing to follow — remove the symlink and ensure the directory is on the ramdisk", + secrets_dir.display() + )); + } + if !md.is_dir() || md.uid() != rustix::process::geteuid().as_raw() { + return Err(format!( + "{}: missing or not owned by this service user (is postmaster-ramdisk healthy?)", + secrets_dir.display() + )); + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn mount_guard_parses_mount_output() { + let out = "/dev/disk3 on / (apfs, local)\n/dev/disk7 on /private/var/run/postmaster (hfs, local, nodev, nosuid, noexec)\n"; + assert!(mount_lists_hfs( + out, + Path::new("/private/var/run/postmaster") + )); + assert!(!mount_lists_hfs(out, Path::new("/private/var/run/other"))); + assert!(!mount_lists_hfs("/dev/disk3 on / (apfs)\n", Path::new("/"))); + } + + #[test] + fn mount_device_for_extracts_device() { + let out = "/dev/disk3 on / (apfs, local)\n/dev/disk7 on /private/var/run/postmaster (hfs, local, nodev, nosuid, noexec)\n"; + assert_eq!( + mount_device_for(out, Path::new("/private/var/run/postmaster")), + Some("/dev/disk7".to_string()) + ); + assert_eq!(mount_device_for(out, Path::new("/nonexistent")), None); + } + + #[test] + fn device_is_ram_backed_detects_ram_prefix() { + let hdiutil = "\ +framework : 683.100.3 +driver : 683.100.3 +================================================ +image-path : ram://1024 +shadow-path : +icon-path : /System/Library/PrivateFrameworks/DiskImages.framework/Resources/CDiskImage.icns +image-type : read/write +system-image : false +blockcount : 1024 +blocksize : 512 +writeable : TRUE +autodiskmount : false +removable : TRUE +-- +framework name : DiskImages +/dev/disk8 +"; + assert!(device_is_ram_backed("/dev/disk8", hdiutil)); + + let hdiutil_file = "\ +================================================ +image-path : /Users/user/image.dmg +shadow-path : +image-type : read/write +/dev/disk9 +"; + assert!(!device_is_ram_backed("/dev/disk9", hdiutil_file)); + + // Device not in hdiutil output at all + assert!(!device_is_ram_backed("/dev/disk99", hdiutil)); + } +} diff --git a/src/secret_files.rs b/src/secret_files.rs new file mode 100644 index 0000000..c263f5d --- /dev/null +++ b/src/secret_files.rs @@ -0,0 +1,420 @@ +//! Secret file operations — writing, cleanup, and pruning of plaintext +//! secret files on RAM-backed filesystems. +//! +//! All file operations use `O_NOFOLLOW` and fd-based permissions to +//! eliminate TOCTOU race conditions against symlink attacks. The secrets +//! directory is pinned once by an open fd (`O_DIRECTORY|O_NOFOLLOW`) and +//! every create, stat, and unlink is relative to that pinned inode — +//! paths are never re-resolved. + +use std::ffi::OsString; +use std::fs; +use std::io::Write; +use std::os::fd::{AsFd, OwnedFd}; +use std::os::unix::ffi::OsStringExt; +use std::path::{Path, PathBuf}; + +use zeroize::Zeroizing; + +/// Resolved values keyed by name, each zeroized on drop. +pub type Resolved = Vec<(String, Zeroizing>)>; + +/// Marker file postmaster creates in any secrets_dir it writes to +/// (audit 2026-08-02 A19). `prune_stale` and `cleanup` refuse to +/// bulk-delete in a non-empty directory that lacks it — foreign files +/// could be present. A permission-mode check cannot make that +/// distinction: the Linux files-mode secrets dir is a namespace-private +/// tmpfs systemd mounts 1777, which is dedicated yet world-accessible. +pub const MANAGED_SENTINEL: &str = ".postmaster-managed"; + +/// Resolved values → env pairs. Env values cannot hold NUL; fail closed +/// rather than truncate. +pub fn env_pairs(resolved: &Resolved) -> Result, String> { + let mut out = Vec::with_capacity(resolved.len()); + for (k, v) in resolved { + if v.contains(&0) { + return Err(format!( + "{k}: value contains a NUL byte; cannot be an environment variable (use files mode)" + )); + } + out.push((OsString::from(k.clone()), OsString::from_vec(v.to_vec()))); + } + Ok(out) +} + +/// Open and pin a secrets directory by fd: `O_RDONLY|O_DIRECTORY| +/// O_NOFOLLOW|O_CLOEXEC` rejects a symlink or non-directory at open and +/// pins the inode, so subsequent `*at` operations are relative to it +/// rather than to a re-resolved path (audit 2026-08-02 A12). +pub(crate) fn open_secrets_dir(dir: &Path) -> rustix::io::Result { + rustix::fs::open( + dir, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) +} + +/// Enumerate entry names beneath an already-pinned directory fd, +/// excluding `.` and `..`. `Dir::read_from` re-opens `.` beneath the +/// fd, so enumeration is relative to the pinned inode and the caller's +/// fd offset is untouched. A directory removed after it was pinned +/// reads as empty. +pub(crate) fn read_dir_names(dir_fd: impl AsFd) -> rustix::io::Result> { + let reader = match rustix::fs::Dir::read_from(dir_fd) { + Ok(r) => r, + // Raced away between the open and here: nothing inside. + Err(rustix::io::Errno::NOENT) => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + let mut names = Vec::new(); + for entry in reader { + let entry = entry?; + let name = entry.file_name().to_bytes(); + if name == b"." || name == b".." { + continue; + } + names.push(OsString::from_vec(name.to_vec())); + } + Ok(names) +} + +/// Writes `value` to `key` beneath the pinned secrets-dir fd `dir_fd` +/// and returns `dir.join(key)` for the `{KEY}_FILE` pointer. Invariant +/// this enforces: plaintext lands only on the mount `verify_ramdisk` +/// already checked — a symlink planted at the target path (e.g. by +/// leftover/attacker-controlled state in a per-service ramdisk dir that +/// persists across restarts) must never be followed onto persistent +/// disk, so the open refuses symlinks (`O_NOFOLLOW`) and fails closed +/// instead. The directory is pinned by the caller's fd (audit +/// 2026-08-02 A12): a symlink swapped in at `dir` after the caller's +/// checks is rejected, and the file open is relative to the pinned +/// directory inode, not a path re-resolution. On any post-open error +/// the just-created/truncated target is unlinked fd-relative before the +/// error is returned — the caller only learns the names of successful +/// writes, so it could never clean up a partial file itself. +pub fn write_secret_file( + dir_fd: impl AsFd, + dir: &Path, + key: &str, + value: &[u8], +) -> Result { + let dir_fd = dir_fd.as_fd(); + let path = dir.join(key); + // Drop the managed-dir sentinel (audit 2026-08-02 A19): prune_stale + // and cleanup refuse to bulk-delete in a non-empty dir lacking it. + let _sentinel_fd = rustix::fs::openat( + dir_fd, + MANAGED_SENTINEL, + rustix::fs::OFlags::WRONLY + | rustix::fs::OFlags::CREATE + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::from_bits_truncate(0o600), + ) + .map_err(|e| format!("{}: sentinel: {e}", path.display()))?; + let oflags = rustix::fs::OFlags::WRONLY + | rustix::fs::OFlags::CREATE + | rustix::fs::OFlags::TRUNC + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC; + // `key` is a validated identifier (no separators), so this opens + // exactly `dir/key` beneath the pinned directory fd. + let fd = rustix::fs::openat( + dir_fd, + key, + oflags, + rustix::fs::Mode::from_bits_truncate(0o600), + ) + .map_err(|e| format!("{}: {e} (refusing to follow a symlink)", path.display()))?; + // Use fchmod on the fd (not set_permissions on the path) to eliminate a + // TOCTOU: between open(O_NOFOLLOW) and a path-based chmod, an attacker + // with directory write access could unlink the file and create a symlink + // at the same path. fchmod operates on the already-opened fd, which is + // the file we just created — no path re-resolution occurs. + if let Err(e) = rustix::fs::fchmod(&fd, rustix::fs::Mode::from_bits_truncate(0o600)) { + // Post-open failure: unlink the just-created target fd-relative + // so partial plaintext never survives a failed write. + let _ = rustix::fs::unlinkat(dir_fd, key, rustix::fs::AtFlags::empty()); + return Err(format!("{}: fchmod: {e}", path.display())); + } + let mut f = fs::File::from(fd); + if let Err(e) = f.write_all(value).and_then(|()| f.flush()) { + // Same invariant: the truncated/partially-written target must + // not persist as stale plaintext on the ramdisk. + let _ = rustix::fs::unlinkat(dir_fd, key, rustix::fs::AtFlags::empty()); + return Err(format!("{}: write: {e}", path.display())); + } + Ok(path) +} + +/// Unlink the named entries beneath the pinned secrets-dir fd +/// (best-effort) after a mid-loop failure so stale plaintext files do +/// not persist on the ramdisk/tmpfs. Unlinking by NAME relative to the +/// pinned fd (not by path) keeps the deletes on the inode the writes +/// went to even if the directory path has been swapped since. The +/// caller must only push names of successfully-written secret files +/// here; an already-vanished entry is ignored. +pub fn cleanup_written(dir_fd: impl AsFd, names: &[String]) { + let dir_fd = dir_fd.as_fd(); + for name in names { + let _ = rustix::fs::unlinkat(dir_fd, name, rustix::fs::AtFlags::empty()); + } +} + +/// Prune stale secret files from `dir` that are not in `keep_keys`. This +/// removes plaintext files left behind by a previous launch whose config +/// has since changed (a key removed from credential_keys or a resolved key +/// set that no longer includes a prior entry). Best-effort: logs but does +/// not fail if a file cannot be removed, since the primary invariant — +/// plaintext is transient — is best served by cleaning what we can rather +/// than aborting the launch over an unrelated stale file. +pub fn prune_stale(dir: &Path, keep_keys: &[String]) -> Result<(), String> { + let keep: std::collections::HashSet<&str> = keep_keys.iter().map(String::as_str).collect(); + // Pin the directory by fd: O_DIRECTORY|O_NOFOLLOW rejects a symlinked + // or non-directory secrets dir at open, and the sentinel check, the + // per-entry stat, and every unlink below are all relative to the + // pinned inode — no path is ever re-resolved (audit 2026-08-02 A12). + let dir_fd = match open_secrets_dir(dir) { + Ok(fd) => fd, + // If the directory does not exist yet, there is nothing to prune. + Err(rustix::io::Errno::NOENT) => return Ok(()), + Err(e @ (rustix::io::Errno::LOOP | rustix::io::Errno::NOTDIR)) => { + return Err(format!( + "{}: prune: refusing a symlinked or non-directory secrets dir ({e})", + dir.display() + )); + } + Err(e) => return Err(format!("{}: prune: {e}", dir.display())), + }; + let names = read_dir_names(&dir_fd).map_err(|e| format!("{}: prune: {e}", dir.display()))?; + // An empty directory is trivially safe (first launch). + if names.is_empty() { + return Ok(()); + } + // Managed-dir guard (audit 2026-08-02 A19): non-empty and no + // sentinel means postmaster never wrote here — bulk deletes could + // hit foreign files. Refuse. + match rustix::fs::statat( + &dir_fd, + MANAGED_SENTINEL, + rustix::fs::AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(stat) if rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file() => {} + _ => { + return Err(format!( + "{}: prune: no {MANAGED_SENTINEL} sentinel; refusing to bulk-delete in a directory postmaster did not write to", + dir.display() + )); + } + } + for name in &names { + let Some(name_str) = name.to_str() else { + continue; + }; + // The sentinel is infrastructure, not a secret — never prune it. + if name_str == MANAGED_SENTINEL || keep.contains(name_str) { + continue; + } + // Only unlink regular files; skip subdirectories and symlinks to + // avoid following attacker-controlled links in a shared dir. Both + // the stat (SYMLINK_NOFOLLOW) and the unlink are relative to the + // pinned dir fd, so a swapped entry cannot redirect either one. + let path = dir.join(name); + match rustix::fs::statat(&dir_fd, name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW) { + Ok(stat) if rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file() => { + if let Err(e) = rustix::fs::unlinkat(&dir_fd, name, rustix::fs::AtFlags::empty()) { + crate::server::log(&format!("{}: prune: {e}", path.display())); + } + } + Ok(stat) => { + crate::server::log(&format!( + "{}: prune: skipping non-file (type {:?})", + path.display(), + rustix::fs::FileType::from_raw_mode(stat.st_mode) + )); + } + Err(e) => { + crate::server::log(&format!("{}: prune: stat: {e}", path.display())); + } + } + } + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::panic)] +mod tests { + use super::*; + + fn tdir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pm-secretfiles-{tag}-{}", std::process::id())); + let _ = fs::remove_dir_all(&d); + fs::create_dir_all(&d).unwrap(); + // prune_stale requires a postmaster-managed (owner-only) dir + // (audit 2026-08-02 A19); match real deployments. + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&d, fs::Permissions::from_mode(0o700)).unwrap(); + d + } + + #[test] + fn env_pairs_rejects_interior_nul() { + let ok = vec![("A".to_string(), Zeroizing::new(b"x\ny".to_vec()))]; + assert_eq!(env_pairs(&ok).unwrap().len(), 1); + let bad = vec![("A".to_string(), Zeroizing::new(b"x\0y".to_vec()))]; + assert!(env_pairs(&bad).unwrap_err().contains("NUL")); + } + + #[test] + fn write_secret_file_writes_0600_via_pinned_fd() { + use std::os::unix::fs::PermissionsExt; + let dir = tdir("write-ok"); + let dir_fd = open_secrets_dir(&dir).unwrap(); + + let path = write_secret_file(&dir_fd, &dir, "KEY", b"plaintext").unwrap(); + + assert_eq!(fs::read(&path).unwrap(), b"plaintext"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600, + "secret file must be owner-only" + ); + assert!( + dir.join(MANAGED_SENTINEL).exists(), + "the write drops the managed-dir sentinel (A19)" + ); + } + + #[test] + fn write_secret_file_refuses_symlink() { + let dir = tdir("symlink"); + // A persistent-disk target that must never receive the plaintext. + let real_target = dir.join("real-target"); + fs::write(&real_target, b"untouched").unwrap(); + // Attacker-planted symlink at the path write_secret_file will target. + let link_path = dir.join("KEY"); + std::os::unix::fs::symlink(&real_target, &link_path).unwrap(); + + let dir_fd = open_secrets_dir(&dir).unwrap(); + let result = write_secret_file(&dir_fd, &dir, "KEY", b"attacker-controlled-plaintext"); + + assert!( + result.is_err(), + "write_secret_file must refuse to follow a symlink at the target path" + ); + assert_eq!( + fs::read(&real_target).unwrap(), + b"untouched", + "the symlink's target file must not be written to" + ); + } + + #[test] + fn prune_stale_removes_unconfigured_files() { + let dir = tdir("prune"); + // Simulate a previous launch that wrote KEY_A and KEY_B (the + // sentinel marks the dir as postmaster-managed — A19). + fs::write(dir.join("KEY_A"), b"old-a").unwrap(); + fs::write(dir.join("KEY_B"), b"old-b").unwrap(); + fs::write(dir.join(MANAGED_SENTINEL), b"").unwrap(); + + // New config only keeps KEY_A; KEY_B should be pruned. + prune_stale(&dir, &["KEY_A".to_string()]).unwrap(); + + assert!(dir.join("KEY_A").exists(), "KEY_A should survive prune"); + assert!(!dir.join("KEY_B").exists(), "KEY_B should be pruned"); + assert!( + dir.join(MANAGED_SENTINEL).exists(), + "sentinel is never pruned" + ); + } + + #[test] + fn prune_stale_refuses_unmanaged_dir() { + // audit 2026-08-02 A19: a non-empty dir without the sentinel was + // never written by postmaster; bulk deletes could hit foreign + // files. + let dir = tdir("prune-unmanaged"); + fs::write(dir.join("FOREIGN"), b"not-ours").unwrap(); + + let err = prune_stale(&dir, &[]).unwrap_err(); + + assert!(err.contains("sentinel"), "{err}"); + assert!(dir.join("FOREIGN").exists(), "nothing may be deleted"); + } + + #[test] + fn prune_stale_refuses_symlinked_dir() { + // A symlink swapped in at the secrets-dir path itself must be + // rejected at open (O_DIRECTORY|O_NOFOLLOW), never traversed. + let dir = tdir("prune-symlinked"); + let real = dir.join("real"); + fs::create_dir(&real).unwrap(); + fs::write(real.join("FOREIGN"), b"not-ours").unwrap(); + let link = dir.join("link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + let err = prune_stale(&link, &[]).unwrap_err(); + + assert!(err.contains("refusing"), "{err}"); + assert!(real.join("FOREIGN").exists(), "nothing may be deleted"); + } + + #[test] + fn prune_stale_empty_dir_needs_no_sentinel() { + // First launch: the secrets dir exists but is empty — nothing to + // prune, no sentinel required. + let dir = tdir("prune-empty"); + prune_stale(&dir, &[]).unwrap(); + } + + #[test] + fn prune_stale_skips_non_files_and_missing_dir() { + let dir = tdir("prune-nonfile"); + // A subdirectory should be skipped, not unlinked. + fs::create_dir(dir.join("subdir")).unwrap(); + // A file that is kept should survive. + fs::write(dir.join("KEEP_ME"), b"data").unwrap(); + // A symlink is skipped too — prune never follows links in a + // shared dir. + std::os::unix::fs::symlink(dir.join("KEEP_ME"), dir.join("a-link")).unwrap(); + fs::write(dir.join(MANAGED_SENTINEL), b"").unwrap(); + + prune_stale(&dir, &["KEEP_ME".to_string()]).unwrap(); + assert!(dir.join("KEEP_ME").exists()); + assert!(dir.join("subdir").is_dir(), "subdir should not be removed"); + assert!( + fs::symlink_metadata(dir.join("a-link")) + .unwrap() + .file_type() + .is_symlink(), + "symlink should be skipped, not unlinked" + ); + + // Non-existent directory: no error, nothing to prune. + let ghost = dir.join("does-not-exist"); + assert!(prune_stale(&ghost, &["X".to_string()]).is_ok()); + } + + #[test] + fn cleanup_written_unlinks_files() { + let dir = tdir("cleanup"); + let f1 = dir.join("A"); + let f2 = dir.join("B"); + fs::write(&f1, b"secret-a").unwrap(); + fs::write(&f2, b"secret-b").unwrap(); + let dir_fd = open_secrets_dir(&dir).unwrap(); + + // A name that does not exist is ignored (best-effort). + cleanup_written( + &dir_fd, + &["A".to_string(), "B".to_string(), "MISSING".to_string()], + ); + + assert!(!f1.exists(), "cleanup should unlink f1"); + assert!(!f2.exists(), "cleanup should unlink f2"); + } +} diff --git a/src/server.rs b/src/server.rs index 93e9f4b..f630e27 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3,8 +3,6 @@ //! This module contains the bulk of the runtime behavior while keeping //! main.rs thin. -#![allow(missing_docs, missing_debug_implementations)] - use std::fs; use std::io::{Read, Write}; use std::net::Shutdown; @@ -31,11 +29,24 @@ const SD_LISTEN_FDS_START: RawFd = 3; // stderr IS the daemon's log transport (journald on Linux, launchd // StandardErrorPath on darwin) — this is the one sanctioned print site. +/// Log a message to stderr (journald on Linux, launchd on macOS). #[allow(clippy::print_stderr)] pub fn log(msg: &str) { eprintln!("postmaster: {msg}"); } +/// Serve a single credential entry on a Unix socket. Handles one peer +/// at a time (thread-per-socket); each peer must be PID 1 or an allowed +/// service user. +/// +/// Connection limiting (audit 2026-08-02 A25, documented decision): the +/// loop is serialized — one connection at a time, each accepted stream +/// closed at the end of its iteration, writes bounded by WRITE_TIMEOUT — +/// so per-connection FDs/threads never accumulate and pending connects +/// are bounded by the kernel backlog. A trusted peer (root or the +/// configured peer_user) can starve the listener with slow connections; +/// that is the authorized consumer of this socket, so no rate limiting +/// is implemented. pub fn serve( listener: UnixListener, path: PathBuf, @@ -86,16 +97,40 @@ pub fn serve( } } -/// Connect, read one credential to EOF. Zero bytes means the server -/// refused to serve — fail closed, exactly like LoadCredential failing. +/// Maximum bytes accepted for any single secret, bundle, or config +/// read. These payloads are small; the cap keeps a hostile or broken +/// peer (or a swapped-in special file) from forcing unbounded heap +/// allocation (audit 2026-08-02 A6). 16 MiB is far above any legitimate +/// payload. +const MAX_READ_BYTES: u64 = 16 * 1024 * 1024; + +/// Read `reader` to EOF with a hard size cap (audit 2026-08-02 A6). +/// Fails rather than truncates when the stream exceeds the cap — a +/// truncated secret must never be served as if complete. +pub(crate) fn read_capped(reader: R, path: &Path) -> Result, String> { + let mut limited = reader.take(MAX_READ_BYTES + 1); + let mut buf = Vec::new(); + limited + .read_to_end(&mut buf) + .map_err(|e| format!("{}: read: {e}", path.display()))?; + if buf.len() as u64 > MAX_READ_BYTES { + return Err(format!( + "{}: exceeds the {}-byte size cap", + path.display(), + MAX_READ_BYTES + )); + } + Ok(buf) +} + +/// Connect, read one credential to EOF (hard-capped by `read_capped`). +/// Zero bytes means the server refused to serve — fail closed, exactly +/// like LoadCredential failing. pub fn fetch_bytes(path: &Path) -> Result>, String> { - let mut stream = + let stream = UnixStream::connect(path).map_err(|e| format!("{}: connect: {e}", path.display()))?; let _ = stream.set_read_timeout(Some(WRITE_TIMEOUT)); - let mut buf = Zeroizing::new(Vec::new()); - stream - .read_to_end(&mut buf) - .map_err(|e| format!("{}: read: {e}", path.display()))?; + let buf = Zeroizing::new(read_capped(stream, path)?); if buf.is_empty() { return Err(format!( "{}: server sent zero bytes (refused to serve); failing closed", @@ -157,48 +192,89 @@ fn peer_euid(stream: &UnixStream) -> std::io::Result { Ok(euid) } +/// Apply process-level hardening: disable core dumps, prevent ptrace +/// attachment, and pin memory (Linux only). Called once at startup. #[cfg(target_os = "linux")] pub fn harden_process() { - // No core dumps, no ptrace from non-privileged peers. - let _ = rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable); + // No core dumps, no ptrace from non-privileged peers. Best-effort by + // policy, but a failure is security-relevant — log it (audit + // 2026-08-02 A10). + if let Err(e) = + rustix::process::set_dumpable_behavior(rustix::process::DumpableBehavior::NotDumpable) + { + log(&format!( + "hardening: set_dumpable(NotDumpable) failed: {e} — core dumps/ptrace not disabled" + )); + } // Best effort: keep key material off swap even without the unit-level - // MemorySwapMax=0 belt (may fail under RLIMIT_MEMLOCK; that's fine). - let _ = rustix::mm::mlockall( - rustix::mm::MlockAllFlags::CURRENT | rustix::mm::MlockAllFlags::FUTURE, - ); + // MemorySwapMax=0 belt (may fail under RLIMIT_MEMLOCK; that's fine — + // but say so). + if let Err(e) = + rustix::mm::mlockall(rustix::mm::MlockAllFlags::CURRENT | rustix::mm::MlockAllFlags::FUTURE) + { + log(&format!( + "hardening: mlockall failed: {e} — decrypted secrets may swap to disk (check RLIMIT_MEMLOCK)" + )); + } } +/// Apply process-level hardening: disable core dumps and prevent ptrace +/// attachment (macOS only). Called once at startup. #[cfg(target_os = "macos")] pub fn harden_process() { // Closest analog to PR_SET_DUMPABLE for dumps: no core files, ever. - let _ = rustix::process::setrlimit( + // Best-effort by policy, but failures are security-relevant — log + // them (audit 2026-08-02 A10). + if let Err(e) = rustix::process::setrlimit( rustix::process::Resource::Core, rustix::process::Rlimit { current: Some(0), maximum: Some(0), }, - ); + ) { + log(&format!( + "hardening: setrlimit(Core, 0) failed: {e} — core dumps not disabled" + )); + } // macOS has no mlockall(2); its swap is encrypted by default, so pages // that do swap out are ciphertext at rest. Deny debugger attach in // release builds (the ptrace-from-peers half of PR_SET_DUMPABLE). #[cfg(not(debug_assertions))] - // SAFETY: PT_DENY_ATTACH takes no pointer arguments; null addr and zero - // data are the documented invocation. - unsafe { - libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0); + { + // SAFETY: PT_DENY_ATTACH takes no pointer arguments; null addr and + // zero data are the documented invocation. + let rc = unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + log(&format!( + "hardening: PT_DENY_ATTACH failed: {err} — debugger attach not denied" + )); + } } } /// Resolve a user name to a uid without getpwnam FFI. pub fn uid_of(name: &str) -> Result { + // Trust boundary (audit 2026-08-02 O2): a compromised `id` returning + // a wrong uid would defeat the socket peer-uid ACL. macOS invokes + // /usr/bin/id by absolute path under SIP, verified via codesign. + // Linux never uses PATH resolution — a PATH-resolved `id` would + // inherit a potentially attacker-controlled PATH; instead the first + // candidate absolute path that verifies (regular file, root-owned, + // no group/other write bits — checked by fstat on an + // O_NOFOLLOW|CLOEXEC fd) is invoked, and if none verifies the lookup + // fails closed. #[cfg(target_os = "macos")] - const ID: &str = "/usr/bin/id"; + let id: &str = { + crate::keys::verify_external_binary(Path::new("/usr/bin/id"))?; + "/usr/bin/id" + }; #[cfg(not(target_os = "macos"))] - const ID: &str = "id"; - let out = std::process::Command::new(ID) + let id: &str = verified_id_binary()?; + let out = std::process::Command::new(id) .args(["-u", name]) .output() - .map_err(|e| format!("{ID}: {e}"))?; + .map_err(|e| format!("{id}: {e}"))?; if !out.status.success() { return Err(format!("peer_user {name:?}: no such user")); } @@ -208,9 +284,68 @@ pub fn uid_of(name: &str) -> Result { .map_err(|_| format!("peer_user {name:?}: `id -u` output not a uid")) } +/// First `id(1)` candidate that exists and verifies, or a fail-closed +/// error listing why each was rejected (Linux only — see `uid_of`). +/// Candidates are well-known absolute paths in preference order; the +/// NixOS path covers systems without /usr/bin or /bin. +#[cfg(not(target_os = "macos"))] +fn verified_id_binary() -> Result<&'static str, String> { + const CANDIDATES: [&str; 3] = ["/usr/bin/id", "/bin/id", "/run/current-system/sw/bin/id"]; + let mut failures: Vec = Vec::new(); + for path in CANDIDATES { + match verify_id_candidate(path) { + Ok(()) => return Ok(path), + Err(e) => failures.push(e), + } + } + Err(format!( + "no trustworthy `id` binary found (refusing PATH resolution; tried {}): {}", + CANDIDATES.join(", "), + failures.join("; ") + )) +} + +/// Verify one `id(1)` candidate by fstat on the opened fd +/// (O_NOFOLLOW|CLOEXEC, so a symlink at the path is rejected at open): +/// must be a regular file, owned by root, with no group/other write +/// bits. Same fd-based discipline as the keys-file checks — nothing is +/// ever re-resolved by path (audit 2026-08-02 O2). +#[cfg(not(target_os = "macos"))] +fn verify_id_candidate(path: &str) -> Result<(), String> { + let fd = rustix::fs::open( + path, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|e| format!("{path}: {e}"))?; + let stat = rustix::fs::fstat(&fd).map_err(|e| format!("{path}: fstat: {e}"))?; + if !rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file() { + return Err(format!("{path}: not a regular file")); + } + if stat.st_uid != 0 { + return Err(format!( + "{path}: owned by uid {} (expected root/0); refusing to trust", + stat.st_uid + )); + } + if rustix::fs::Mode::from_bits_truncate(stat.st_mode) + .intersects(rustix::fs::Mode::WGRP | rustix::fs::Mode::WOTH) + { + return Err(format!("{path}: group/other writable; refusing to trust")); + } + Ok(()) +} + +/// Resolve the keys file path from a `KeysSource` configuration. pub fn keys_path(source: &KeysSource) -> Result { match source { KeysSource::Credential(name) => { + // Same discipline as the exec-config credential source + // (audit 2026-08-02 A8): the name must be a single relative + // path component — otherwise "../../…" would escape + // $CREDENTIALS_DIRECTORY and read an arbitrary root-readable + // file as key material. + crate::exec_config::validate_credential_source_name(name)?; let dir = std::env::var_os("CREDENTIALS_DIRECTORY").ok_or( "keys.credential configured but $CREDENTIALS_DIRECTORY is unset \ (is LoadCredential wired on the postmaster unit?)", @@ -229,6 +364,8 @@ pub fn keys_path(source: &KeysSource) -> Result { } } +/// Bind and listen on all configured credential sockets. Returns +/// a vector of `(UnixListener, path, entry)` triples ready for serving. pub fn acquire_listeners( cfg: &Config, bind: bool, @@ -247,20 +384,27 @@ pub fn acquire_listeners( // only that user (and root) can even reach the socket. Only // meaningful when we run privileged; dev runs skip it. if am_root && let Some(uid) = expected { - rustix::fs::chown(parent, Some(rustix::fs::Uid::from_raw(uid)), None) - .map_err(|e| format!("{}: chown: {e}", parent.display()))?; - // Audit finding #6: use fchmod on the directory fd - // instead of set_permissions on the path to eliminate a - // TOCTOU between chown and chmod. Open the directory - // read-only with O_DIRECTORY, fchmod the fd, close. + // Open the directory and operate on the fd throughout: + // O_NOFOLLOW rejects a symlink at the final component + // (audit 2026-08-02 A1/A2), and fchown/fchmod on the fd + // act on the pinned inode, immune to path swaps + // (audit finding #6). let dir_fd = rustix::fs::open( parent, rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, rustix::fs::Mode::empty(), ) - .map_err(|e| format!("{}: open dir: {e}", parent.display()))?; + .map_err(|e| { + format!( + "{}: open dir: {e} (refusing to follow a symlink)", + parent.display() + ) + })?; + rustix::fs::fchown(&dir_fd, Some(rustix::fs::Uid::from_raw(uid)), None) + .map_err(|e| format!("{}: fchown: {e}", parent.display()))?; rustix::fs::fchmod(&dir_fd, rustix::fs::Mode::from_bits_truncate(0o500)) .map_err(|e| format!("{}: fchmod: {e}", parent.display()))?; } @@ -339,15 +483,23 @@ pub fn acquire_listeners( } } +/// Run the credential daemon: harden the process, load keys, bind +/// sockets, and serve credential requests. Returns on socket error +/// or after all connections are drained (if not socket-activated). pub fn run(args: &Args) -> Result<(), String> { - let raw = fs::read(&args.config).map_err(|e| format!("{}: {e}", args.config.display()))?; + let file = + fs::File::open(&args.config).map_err(|e| format!("{}: {e}", args.config.display()))?; + let raw = read_capped(file, &args.config)?; let cfg: Config = serde_json::from_slice(&raw).map_err(|e| format!("{}: {e}", args.config.display()))?; if cfg.credentials.is_empty() { return Err("config maps no credentials; nothing to do".into()); } - let ring = Arc::new(KeyRing::load(&keys_path(&cfg.keys)?, KeyNaming::Env)?); + // The daemon's fetch contract is dotenvx-style env entries only; the + // bundle adapter is an exec-mode surface, so the ring requires Env + // naming alone here. + let ring = Arc::new(KeyRing::load(&keys_path(&cfg.keys)?, &[KeyNaming::Env])?); // Preflight: prove every credential is servable — and every peer_user // resolvable — before accepting anyone. @@ -372,7 +524,18 @@ pub fn run(args: &Args) -> Result<(), String> { let listeners = acquire_listeners(&cfg, args.bind, &peer_uids)?; let mut handles = Vec::new(); for (listener, path) in listeners { - let entry = cfg.credentials.get(&path).expect("validated above").clone(); + let entry = match cfg.credentials.get(&path) { + Some(e) => e.clone(), + // Unreachable (listeners derive from cfg.credentials), but a + // clear error beats an abort under panic=abort (audit + // 2026-08-02 A22). + None => { + return Err(format!( + "{}: internal: listener path missing from config", + path.display() + )); + } + }; let peer_uid = peer_uids.get(&path).copied().flatten(); let ring = Arc::clone(&ring); let allow = args.allow_nonroot; @@ -416,4 +579,35 @@ mod tests { assert!(fetch_bytes(&sock).unwrap_err().contains("zero bytes")); t.join().unwrap(); } + + #[test] + #[cfg(not(target_os = "macos"))] + fn verify_id_candidate_rejects_untrusted_file() { + // A user-owned, group/other-writable file must fail: non-root + // runs trip the root-ownership check, root runs trip the + // write-bits check — either way the file is refused. + use std::os::unix::fs::PermissionsExt; + let path = std::env::temp_dir().join(format!("pm-idfake-{}", std::process::id())); + fs::write(&path, b"fake id").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).unwrap(); + let p = path.to_str().unwrap().to_string(); + + let err = verify_id_candidate(&p).unwrap_err(); + + let _ = fs::remove_file(&path); + assert!( + err.contains("owned by uid") || err.contains("writable"), + "{err}" + ); + } + + #[test] + #[cfg(not(target_os = "macos"))] + fn verified_id_binary_finds_a_system_id() { + // On any Linux host at least one candidate must verify — glibc + // systems have /usr/bin/id or /bin/id, NixOS has + // /run/current-system/sw/bin/id. + let id = verified_id_binary().unwrap(); + assert!(id.starts_with('/')); + } } diff --git a/src/test_util.rs b/src/test_util.rs new file mode 100644 index 0000000..c07c015 --- /dev/null +++ b/src/test_util.rs @@ -0,0 +1,15 @@ +//! Test-only shared utilities (compiled under `#[cfg(test)]` only). + +/// Process-wide lock serializing every unit test that mutates — or +/// wholesale snapshots — the process-global environment. The test +/// harness runs tests in parallel threads of one process, so +/// unsynchronized `set_var`/`remove_var`, or a full `vars()` scan +/// racing another test's mutation, is a data race; edition 2024 marks +/// those calls `unsafe`. +/// +/// ALL env-touching tests in every module hold this ONE lock for the +/// mutation's full scope. Module-local locks do not exclude each other: +/// separate per-module statics raced, and the loser's panic poisoned a +/// mutex, failing unrelated tests (~1 in 5 baseline runs, observed +/// 2026-08-04). +pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); diff --git a/tests/exec_check.rs b/tests/exec_check.rs index f85be0f..5b9c6e8 100644 --- a/tests/exec_check.rs +++ b/tests/exec_check.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use postmaster::config::ExecConfig; +use postmaster::exec_config::ExecConfig; fn repo_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() diff --git a/tests/exec_conformance.rs b/tests/exec_conformance.rs deleted file mode 100644 index f71886e..0000000 --- a/tests/exec_conformance.rs +++ /dev/null @@ -1,662 +0,0 @@ -#![allow(clippy::unwrap_used, clippy::panic, missing_docs)] - -mod common; - -use postmaster::adapter::KeyNaming; - -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::process::Command; - -#[test] -fn helper_ciphertext_decrypts_with_test_key() { - let v = common::enc("hello $(rm -rf /) `x` \"q\" \nline2"); - let b64 = v.strip_prefix("encrypted:").unwrap(); - let ct = { - use base64::Engine; - base64::engine::general_purpose::STANDARD - .decode(b64) - .unwrap() - }; - let sk = hex::decode(common::TEST_SK_HEX).unwrap(); - let pt = ecies::decrypt(&sk, &ct).unwrap(); - assert_eq!(pt, b"hello $(rm -rf /) `x` \"q\" \nline2"); -} - -#[test] -fn wrapped_base64_ciphertext_decrypts() { - // Test that decrypt_value tolerates ASCII whitespace in base64 payloads, - // matching dotenvx-rs's lenient decoding behavior. - let plaintext = "wrapped-value"; - let v = common::enc(plaintext); - let b64 = v.strip_prefix("encrypted:").unwrap(); - - // Wrap the base64 by inserting newlines every 20 chars - let mut wrapped = String::from("encrypted:"); - for (i, c) in b64.chars().enumerate() { - if i > 0 && i % 20 == 0 { - wrapped.push('\n'); - } - wrapped.push(c); - } - wrapped.push('\n'); - - // Create a temporary keys file with TEST_SK_HEX - let d = common::tmpdir("wrapped-base64"); - common::write_keys_file(&d, "", &[common::TEST_SK_HEX]); - let keys_path = d.join(".env.keys"); - - // Load the key ring - let ring = postmaster::keys::KeyRing::load(&keys_path, KeyNaming::Env).unwrap(); - - // Create a dummy env file path for error messages - let dummy_env = d.join(".env"); - - // decrypt_value should handle the wrapped (whitespace-containing) payload - let result = - postmaster::keys::decrypt_value(&dummy_env, "TEST_VAR", &wrapped, &ring, KeyNaming::Env); - - assert!(result.is_ok(), "wrapped base64 should decrypt successfully"); - let decrypted = result.unwrap(); - assert_eq!(decrypted.as_slice(), plaintext.as_bytes()); -} - -fn run_exec(config: &Path, sh: &str, envs: &[(&str, &str)]) -> std::process::Output { - let mut c = Command::new(env!("CARGO_BIN_EXE_postmaster")); - c.args(["exec", "--config"]) - .arg(config) - .args(["--", "/bin/sh", "-c", sh]) - // Hermeticity: an ambient SECRET/PLAIN/A in the test runner's own - // environment would flip the precedence-test outcomes below, since - // these are exactly the value-variable names the suite asserts on. - .env_remove("SECRET") - .env_remove("PLAIN") - .env_remove("A"); - for (k, v) in envs { - c.env(k, v); - } - c.output().unwrap() -} - -fn write_config(dir: &Path, json: &str) -> std::path::PathBuf { - let p = dir.join("exec.json"); - std::fs::write(&p, json).unwrap(); - p -} - -#[test] -fn env_mode_injects_decrypted_metacharacter_values_inert() { - let d = common::tmpdir("envmode"); - let val = "p@ss;|&`$(boom)\"'\nline2"; - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), - format!("SECRET=\"{}\"", common::enc(val)), - "PLAIN=just-plain".to_string(), - ], - ); - let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, - keys.display(), - env.display() - ), - ); - let sh = format!( - "printf %s \"$SECRET\" > '{d}'/secret.out && printf %s \"$PLAIN\" > '{d}'/plain.out && [ -z \"${{DOTENV_PUBLIC_KEY_PRODUCTION:-}}\" ]", - d = d.display() - ); - let out = run_exec(&cfg, &sh, &[]); - assert!( - out.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!(std::fs::read(d.join("secret.out")).unwrap(), val.as_bytes()); - assert_eq!(std::fs::read(d.join("plain.out")).unwrap(), b"just-plain"); -} - -#[test] -fn env_mode_process_env_wins_without_overload() { - let d = common::tmpdir("envwins"); - let val = "decrypted-value"; - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), - format!("SECRET=\"{}\"", common::enc(val)), - ], - ); - let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); - - let cfg_no_overload = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"overload":false}}"#, - keys.display(), - env.display() - ), - ); - let sh1 = format!("printf %s \"$SECRET\" > '{d}'/out1", d = d.display()); - let out1 = run_exec(&cfg_no_overload, &sh1, &[("SECRET", "preset")]); - assert!( - out1.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out1.stderr) - ); - assert_eq!(std::fs::read(d.join("out1")).unwrap(), b"preset"); - - let cfg_overload = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"overload":true}}"#, - keys.display(), - env.display() - ), - ); - let sh2 = format!("printf %s \"$SECRET\" > '{d}'/out2", d = d.display()); - let out2 = run_exec(&cfg_overload, &sh2, &[("SECRET", "preset")]); - assert!( - out2.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out2.stderr) - ); - assert_eq!(std::fs::read(d.join("out2")).unwrap(), val.as_bytes()); -} - -#[test] -fn rotation_second_key_decrypts() { - let d = common::tmpdir("rotation"); - let val = "rotated-secret"; - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), - format!("SECRET=\"{}\"", common::enc(val)), - ], - ); - let bogus = "22".repeat(32); - let keys = common::write_keys_file(&d, "PRODUCTION", &[&bogus, common::TEST_SK_HEX]); - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, - keys.display(), - env.display() - ), - ); - let sh = format!("printf %s \"$SECRET\" > '{d}'/out", d = d.display()); - let out = run_exec(&cfg, &sh, &[]); - assert!( - out.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!(std::fs::read(d.join("out")).unwrap(), val.as_bytes()); -} - -#[test] -fn strict_failure_refuses_exec() { - let d = common::tmpdir("strictfail"); - // Encrypt with a DIFFERENT key than the one in our keyring, so - // decryption must fail even though the ciphertext is well-formed. - let other_sk_hex = "22".repeat(32); - let other_sk_bytes = hex::decode(&other_sk_hex).unwrap(); - let other_sk = ecies::SecretKey::parse_slice(&other_sk_bytes).unwrap(); - let other_pk = ecies::PublicKey::from_secret_key(&other_sk); - let ct = ecies::encrypt(&other_pk.serialize_compressed(), b"unreachable").unwrap(); - let b64 = { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(ct) - }; - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!( - "DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", - hex::encode(other_pk.serialize_compressed()) - ), - format!("SECRET=\"encrypted:{b64}\""), - ], - ); - let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"strict":true}}"#, - keys.display(), - env.display() - ), - ); - let sh = format!("printf %s \"$SECRET\" > '{d}'/out", d = d.display()); - let out = run_exec(&cfg, &sh, &[]); - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("no key under"), "stderr: {stderr}"); - assert!(!d.join("out").exists()); -} - -#[test] -fn files_mode_writes_0600_pointers_and_passthrough() { - let d = common::tmpdir("filesmode"); - let secrets_dir = d.join("secrets"); - std::fs::create_dir_all(&secrets_dir).unwrap(); - let val = "file-secret"; - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), - format!("SECRET=\"{}\"", common::enc(val)), - "PLAIN=passthrough-val".to_string(), - ], - ); - let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"files","keys":[{{"file":"{}"}}],"env_files":["{}"],"secrets_dir":"{}","passthrough":["PLAIN"]}}"#, - keys.display(), - env.display(), - secrets_dir.display() - ), - ); - let sh = format!( - r#"printf %s "$SECRET_FILE" > '{d}'/secret_file.out && \ - cat "$SECRET_FILE" > '{d}'/secret_contents.out && \ - printf %s "${{SECRET:-UNSET}}" > '{d}'/secret_var.out && \ - printf %s "$PLAIN" > '{d}'/plain.out && \ - printf %s "${{POSTMASTER_SECRETS_DIR:-UNSET}}" > '{d}'/secretsdir.out"#, - d = d.display() - ); - let out = run_exec(&cfg, &sh, &[]); - assert!( - out.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - std::fs::read_to_string(d.join("secret_file.out")).unwrap(), - secrets_dir.join("SECRET").display().to_string() - ); - assert_eq!( - std::fs::read(d.join("secret_contents.out")).unwrap(), - val.as_bytes() - ); - assert_eq!(std::fs::read(d.join("secret_var.out")).unwrap(), b"UNSET"); - assert_eq!( - std::fs::read(d.join("plain.out")).unwrap(), - b"passthrough-val" - ); - assert_eq!( - std::fs::read_to_string(d.join("secretsdir.out")).unwrap(), - "UNSET" - ); - - let md = std::fs::metadata(secrets_dir.join("SECRET")).unwrap(); - assert_eq!(md.permissions().mode() & 0o777, 0o600); -} - -#[test] -fn credentials_mode_systemd_pointers() { - let d = common::tmpdir("credsystemd"); - let creds_dir = d.join("creds"); - std::fs::create_dir_all(&creds_dir).unwrap(); - std::fs::write(creds_dir.join("DB_URL"), "abc").unwrap(); - let cfg = write_config(&d, r#"{"mode":"credentials","credential_keys":["DB_URL"]}"#); - let sh = format!( - "printf %s \"$DB_URL_FILE\" > '{d}'/dbfile.out && cat \"$DB_URL_FILE\" > '{d}'/dbcontents.out", - d = d.display() - ); - let out = run_exec( - &cfg, - &sh, - &[("CREDENTIALS_DIRECTORY", creds_dir.to_str().unwrap())], - ); - assert!( - out.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - std::fs::read_to_string(d.join("dbfile.out")).unwrap(), - creds_dir.join("DB_URL").display().to_string() - ); - assert_eq!(std::fs::read(d.join("dbcontents.out")).unwrap(), b"abc"); - - // Without CREDENTIALS_DIRECTORY, the systemd path has nothing to point - // at: fail closed. Explicitly remove from child to ensure hermeticity. - let mut c = Command::new(env!("CARGO_BIN_EXE_postmaster")); - c.args(["exec", "--config"]) - .arg(&cfg) - .args(["--", "/bin/sh", "-c", "true"]) - .env_remove("CREDENTIALS_DIRECTORY"); - let out2 = c.output().unwrap(); - assert!(!out2.status.success()); -} - -#[test] -fn credentials_mode_fetch_loop() { - let d = common::tmpdir("credfetch"); - let secrets_dir = d.join("secrets"); - std::fs::create_dir_all(&secrets_dir).unwrap(); - let sock = d.join("k.sock"); - let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); - let server = std::thread::spawn(move || { - if let Ok((mut s, _)) = listener.accept() { - use std::io::Write; - let _ = s.write_all(b"s3cret"); - let _ = s.shutdown(std::net::Shutdown::Both); - } - }); - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"credentials","credential_keys":["K"],"sockets":{{"K":"{}"}},"secrets_dir":"{}"}}"#, - sock.display(), - secrets_dir.display() - ), - ); - let sh = format!( - "printf %s \"$CREDENTIALS_DIRECTORY\" > '{d}'/creddir.out && cat '{sd}'/K > '{d}'/kcontents.out", - d = d.display(), - sd = secrets_dir.display() - ); - let out = run_exec(&cfg, &sh, &[]); - server.join().unwrap(); - assert!( - out.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - std::fs::read_to_string(d.join("creddir.out")).unwrap(), - secrets_dir.display().to_string() - ); - assert_eq!(std::fs::read(d.join("kcontents.out")).unwrap(), b"s3cret"); - let md = std::fs::metadata(secrets_dir.join("K")).unwrap(); - assert_eq!(md.permissions().mode() & 0o777, 0o600); -} - -#[test] -fn multi_file_first_wins_and_overload_reverses() { - let d = common::tmpdir("multifile"); - let f1 = common::write_env_file(&d, ".env", &["A=first-file".to_string()]); - let f2 = common::write_env_file(&d, ".env.local", &["A=second-file".to_string()]); - let keys = common::write_keys_file(&d, "", &[common::TEST_SK_HEX]); - - let cfg_first = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}","{}"],"overload":false}}"#, - keys.display(), - f1.display(), - f2.display() - ), - ); - let sh1 = format!("printf %s \"$A\" > '{d}'/out1", d = d.display()); - let out1 = run_exec(&cfg_first, &sh1, &[]); - assert!( - out1.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out1.stderr) - ); - assert_eq!(std::fs::read(d.join("out1")).unwrap(), b"first-file"); - - let cfg_overload = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}","{}"],"overload":true}}"#, - keys.display(), - f1.display(), - f2.display() - ), - ); - let sh2 = format!("printf %s \"$A\" > '{d}'/out2", d = d.display()); - let out2 = run_exec(&cfg_overload, &sh2, &[]); - assert!( - out2.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out2.stderr) - ); - assert_eq!(std::fs::read(d.join("out2")).unwrap(), b"second-file"); -} - -#[test] -fn same_file_last_assignment_wins() { - let d = common::tmpdir("samelastwin"); - let first_val = "first-value"; - let second_val = "second-value"; - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), - format!("SECRET=\"{}\"", common::enc(first_val)), - format!("SECRET=\"{}\"", common::enc(second_val)), - ], - ); - let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, - keys.display(), - env.display() - ), - ); - let sh = format!("printf %s \"$SECRET\" > '{d}'/secret.out", d = d.display()); - let out = run_exec(&cfg, &sh, &[]); - assert!( - out.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!( - std::fs::read(d.join("secret.out")).unwrap(), - second_val.as_bytes() - ); -} - -#[test] -fn nul_in_env_value_fails_closed() { - let d = common::tmpdir("nulval"); - let val = "before\0after"; - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), - format!("SECRET=\"{}\"", common::enc(val)), - ], - ); - let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); - - // env mode: fails closed with a message about NUL. - let cfg_env = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, - keys.display(), - env.display() - ), - ); - let out = run_exec(&cfg_env, "true", &[]); - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!(stderr.contains("NUL"), "stderr: {stderr}"); - - // files mode: the exact bytes (including the NUL) reach the file. - let secrets_dir = d.join("secrets"); - std::fs::create_dir_all(&secrets_dir).unwrap(); - let cfg_files = write_config( - &d, - &format!( - r#"{{"mode":"files","keys":[{{"file":"{}"}}],"env_files":["{}"],"secrets_dir":"{}"}}"#, - keys.display(), - env.display(), - secrets_dir.display() - ), - ); - let sh = format!( - "cat '{sd}'/SECRET > '{d}'/out", - sd = secrets_dir.display(), - d = d.display() - ); - let out2 = run_exec(&cfg_files, &sh, &[]); - assert!( - out2.status.success(), - "stderr: {}", - String::from_utf8_lossy(&out2.stderr) - ); - assert_eq!(std::fs::read(d.join("out")).unwrap(), val.as_bytes()); -} - -#[test] -fn strict_false_skips_undecryptable_and_continues() { - let d = common::tmpdir("laxskip"); - // GOOD decrypts with TEST_SK_HEX; BAD uses a different key so it fails. - let other_sk_hex = "33".repeat(32); - let other_sk_bytes = hex::decode(&other_sk_hex).unwrap(); - let other_sk = ecies::SecretKey::parse_slice(&other_sk_bytes).unwrap(); - let other_pk = ecies::PublicKey::from_secret_key(&other_sk); - let bad_ct = ecies::encrypt(&other_pk.serialize_compressed(), b"unreachable").unwrap(); - let bad_b64 = { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(bad_ct) - }; - - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), - format!("GOOD=\"{}\"", common::enc("lax-ok")), - format!("BAD=\"encrypted:{bad_b64}\""), - ], - ); - let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); - // strict:false: BAD should be skipped (logged), GOOD should still reach the child. - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"strict":false}}"#, - keys.display(), - env.display() - ), - ); - let sh = format!( - "printf %s \"${{GOOD:-UNSET}}\" > '{d}'/good.out && printf %s \"${{BAD:-UNSET}}\" > '{d}'/bad.out", - d = d.display() - ); - let out = run_exec(&cfg, &sh, &[]); - assert!( - out.status.success(), - "strict:false should continue past undecryptable value and launch the child\nstderr: {}", - String::from_utf8_lossy(&out.stderr) - ); - assert_eq!(std::fs::read(d.join("good.out")).unwrap(), b"lax-ok"); - // BAD was skipped: the child sees no BAD env var. - assert_eq!(std::fs::read(d.join("bad.out")).unwrap(), b"UNSET"); - // The stderr should mention the skip. - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("non-strict: skipping BAD"), - "expected skip log in stderr, got: {stderr}" - ); -} - -#[test] -fn exec_rejects_non_identifier_key_at_exec_boundary() { - let d = common::tmpdir("badkey"); - // A dotted key like BAD.KEY parses fine as dotenv but is_ident rejects it. - let env = common::write_env_file(&d, ".env", &["BAD.KEY=oops".to_string()]); - let keys = common::write_keys_file(&d, "", &[common::TEST_SK_HEX]); - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, - keys.display(), - env.display() - ), - ); - let out = run_exec(&cfg, "true", &[]); - assert!(!out.status.success()); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("non-identifier"), - "expected 'non-identifier' in stderr, got: {stderr}" - ); -} - -#[test] -fn verify_keys_aborts_on_stale_keys_with_strict_false() { - // Audit finding #4: --verify-keys should catch stale keys even when - // strict is false. Encrypt with a different key than the one in the - // keyring, set strict:false, and pass --verify-keys. The exec must - // abort with a --verify-keys error rather than launching the child - // with no secrets. - let d = common::tmpdir("verifykeys"); - let other_sk_hex = "33".repeat(32); - let other_sk_bytes = hex::decode(&other_sk_hex).unwrap(); - let other_sk = ecies::SecretKey::parse_slice(&other_sk_bytes).unwrap(); - let other_pk = ecies::PublicKey::from_secret_key(&other_sk); - let ct = ecies::encrypt(&other_pk.serialize_compressed(), b"stale-secret").unwrap(); - let b64 = { - use base64::Engine; - base64::engine::general_purpose::STANDARD.encode(ct) - }; - let env = common::write_env_file( - &d, - ".env.production", - &[ - format!( - "DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", - hex::encode(other_pk.serialize_compressed()) - ), - format!("SECRET=\"encrypted:{b64}\""), - ], - ); - // Keyring has the TEST_SK_HEX key, NOT the other key — keys are stale. - let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); - let cfg = write_config( - &d, - &format!( - r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"strict":false}}"#, - keys.display(), - env.display() - ), - ); - // Without --verify-keys, strict:false would silently skip and succeed. - let sh = "true".to_string(); - let out_no_verify = run_exec(&cfg, &sh, &[]); - assert!( - out_no_verify.status.success(), - "without --verify-keys, strict:false should silently skip: {}", - String::from_utf8_lossy(&out_no_verify.stderr) - ); - - // With --verify-keys, the stale keys must be caught. - let mut c = Command::new(env!("CARGO_BIN_EXE_postmaster")); - c.args(["exec", "--config"]) - .arg(&cfg) - .args(["--verify-keys", "--", "true"]) - .env_remove("SECRET"); - let out = c.output().unwrap(); - assert!( - !out.status.success(), - "--verify-keys should abort on stale keys" - ); - let stderr = String::from_utf8_lossy(&out.stderr); - assert!( - stderr.contains("--verify-keys failed"), - "expected '--verify-keys failed' in stderr, got: {stderr}" - ); -} diff --git a/tests/exec_conformance/bundle.rs b/tests/exec_conformance/bundle.rs new file mode 100644 index 0000000..cabb95f --- /dev/null +++ b/tests/exec_conformance/bundle.rs @@ -0,0 +1,125 @@ +//! Bundle conformance tests. +//! +//! End-to-end bundle exec coverage: the actual binary, a real bundle +//! file, and key material loaded through `load_ring` — the seam where +//! the bundle-ring gap lived (unit tests build rings directly and never +//! saw it). + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; + +use crate::common; +use crate::{run_exec, write_config}; + +/// Encrypt `value` for the shared test key with AAD = the entry name, +/// exactly as the bundle adapter's suite requires. +fn enc_entry(name: &str, value: &str) -> String { + let pk = hex::decode(common::test_pk_hex()).unwrap(); + let ct = ecies::encrypt_with_aad(&pk, value.as_bytes(), name.as_bytes()).unwrap(); + BASE64.encode(ct) +} + +/// Write a v1 bundle file with the given (name, value) utf8 entries. +fn write_bundle(dir: &std::path::Path, entries: &[(&str, &str)]) -> std::path::PathBuf { + let entries: serde_json::Map = entries + .iter() + .map(|(name, value)| { + ( + name.to_string(), + serde_json::json!({ + "encoding": "utf8", + "ciphertext": enc_entry(name, value), + }), + ) + }) + .collect(); + let bundle = serde_json::json!({ + "format": "postmaster.bundle", + "version": 1, + "profile": "conformance", + "suite": "ecies-secp256k1-hkdf-sha256-aes-256-gcm-entry-aad", + "recipients": [{ "id": "test", "public_key": common::test_pk_hex() }], + "entries": entries, + }); + let p = dir.join("secrets.bundle.json"); + std::fs::write(&p, serde_json::to_vec_pretty(&bundle).unwrap()).unwrap(); + p +} + +/// A keys file carrying `POSTMASTER_KEY` (and optionally +/// `DOTENV_PRIVATE_KEY`), with the permissions `KeyRing::load` enforces. +fn write_keys(dir: &std::path::Path, with_dotenv: bool) -> std::path::PathBuf { + use std::os::unix::fs::PermissionsExt; + let mut content = format!("POSTMASTER_KEY=\"{}\"\n", common::TEST_SK_HEX); + if with_dotenv { + content.push_str(&format!("DOTENV_PRIVATE_KEY=\"{}\"\n", common::TEST_SK_HEX)); + } + let p = dir.join(".env.keys"); + std::fs::write(&p, content).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap(); + p +} + +#[test] +fn bundle_exec_loads_postmaster_key_through_load_ring() { + // Regression for the bundle-ring gap: load_ring hardcoded + // KeyNaming::Env at every source leg, so POSTMASTER_KEY never entered + // the ring and EVERY bundle config failed at exec with "key ring has + // no POSTMASTER_KEY entry". This test drives the real binary through + // load_ring; it fails on the old code and passes on the fix. + let d = common::tmpdir("bundlee2e"); + let bundle = write_bundle(&d, &[("SECRET", "bundle-secret-value")]); + let keys = write_keys(&d, false); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"bundle_files":["{}"]}}"#, + keys.display(), + bundle.display() + ), + ); + let sh = format!("printf %s \"$SECRET\" > '{d}'/secret.out", d = d.display()); + let out = run_exec(&cfg, &sh, &[]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + std::fs::read(d.join("secret.out")).unwrap(), + b"bundle-secret-value" + ); +} + +#[test] +fn mixed_env_and_bundle_exec_resolves_both_adapters() { + // A config with both env_files and bundle_files requires BOTH + // namings in one ring (the adapter-aware load_ring signature's + // multi-naming path). + let d = common::tmpdir("bundle-mixed"); + let val = "p@ss;|&`$(boom)\"'\nline2"; + let env = common::write_env_file(&d, ".env", &[format!("PLAIN=\"{}\"", common::enc(val))]); + let bundle = write_bundle(&d, &[("SECRET", "from-bundle")]); + let keys = write_keys(&d, true); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"bundle_files":["{}"]}}"#, + keys.display(), + env.display(), + bundle.display() + ), + ); + let sh = format!( + "printf %s \"$SECRET\" > '{d}'/secret.out && printf %s \"$PLAIN\" > '{d}'/plain.out", + d = d.display() + ); + let out = run_exec(&cfg, &sh, &[]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(std::fs::read(d.join("secret.out")).unwrap(), b"from-bundle"); + assert_eq!(std::fs::read(d.join("plain.out")).unwrap(), val.as_bytes()); +} diff --git a/tests/exec_conformance/env_mode.rs b/tests/exec_conformance/env_mode.rs new file mode 100644 index 0000000..9172b29 --- /dev/null +++ b/tests/exec_conformance/env_mode.rs @@ -0,0 +1,244 @@ +use crate::common; +use crate::{run_exec, write_config}; + +#[test] +fn env_mode_injects_decrypted_metacharacter_values_inert() { + let d = common::tmpdir("envmode"); + let val = "p@ss;|&`$(boom)\"'\nline2"; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), + format!("SECRET=\"{}\"", common::enc(val)), + "PLAIN=just-plain".to_string(), + ], + ); + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, + keys.display(), + env.display() + ), + ); + let sh = format!( + "printf %s \"$SECRET\" > '{d}'/secret.out && printf %s \"$PLAIN\" > '{d}'/plain.out && [ -z \"${{DOTENV_PUBLIC_KEY_PRODUCTION:-}}\" ]", + d = d.display() + ); + let out = run_exec(&cfg, &sh, &[]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(std::fs::read(d.join("secret.out")).unwrap(), val.as_bytes()); + assert_eq!(std::fs::read(d.join("plain.out")).unwrap(), b"just-plain"); +} + +#[test] +fn env_mode_process_env_wins_without_overload() { + let d = common::tmpdir("envwins"); + let val = "decrypted-value"; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), + format!("SECRET=\"{}\"", common::enc(val)), + ], + ); + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + + let cfg_no_overload = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"overload":false}}"#, + keys.display(), + env.display() + ), + ); + let sh1 = format!("printf %s \"$SECRET\" > '{d}'/out1", d = d.display()); + let out1 = run_exec(&cfg_no_overload, &sh1, &[("SECRET", "preset")]); + assert!( + out1.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out1.stderr) + ); + assert_eq!(std::fs::read(d.join("out1")).unwrap(), b"preset"); + + let cfg_overload = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"overload":true}}"#, + keys.display(), + env.display() + ), + ); + let sh2 = format!("printf %s \"$SECRET\" > '{d}'/out2", d = d.display()); + let out2 = run_exec(&cfg_overload, &sh2, &[("SECRET", "preset")]); + assert!( + out2.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out2.stderr) + ); + assert_eq!(std::fs::read(d.join("out2")).unwrap(), val.as_bytes()); +} + +#[test] +fn multi_file_first_wins_and_overload_reverses() { + let d = common::tmpdir("multifile"); + let f1 = common::write_env_file(&d, ".env", &["A=first-file".to_string()]); + let f2 = common::write_env_file(&d, ".env.local", &["A=second-file".to_string()]); + let keys = common::write_keys_file(&d, "", &[common::TEST_SK_HEX]); + + let cfg_first = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}","{}"],"overload":false}}"#, + keys.display(), + f1.display(), + f2.display() + ), + ); + let sh1 = format!("printf %s \"$A\" > '{d}'/out1", d = d.display()); + let out1 = run_exec(&cfg_first, &sh1, &[]); + assert!( + out1.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out1.stderr) + ); + assert_eq!(std::fs::read(d.join("out1")).unwrap(), b"first-file"); + + let cfg_overload = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}","{}"],"overload":true}}"#, + keys.display(), + f1.display(), + f2.display() + ), + ); + let sh2 = format!("printf %s \"$A\" > '{d}'/out2", d = d.display()); + let out2 = run_exec(&cfg_overload, &sh2, &[]); + assert!( + out2.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out2.stderr) + ); + assert_eq!(std::fs::read(d.join("out2")).unwrap(), b"second-file"); +} + +#[test] +fn same_file_last_assignment_wins() { + let d = common::tmpdir("samelastwin"); + let first_val = "first-value"; + let second_val = "second-value"; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), + format!("SECRET=\"{}\"", common::enc(first_val)), + format!("SECRET=\"{}\"", common::enc(second_val)), + ], + ); + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, + keys.display(), + env.display() + ), + ); + let sh = format!("printf %s \"$SECRET\" > '{d}'/secret.out", d = d.display()); + let out = run_exec(&cfg, &sh, &[]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + std::fs::read(d.join("secret.out")).unwrap(), + second_val.as_bytes() + ); +} + +#[test] +fn nul_in_env_value_fails_closed() { + let d = common::tmpdir("nulval"); + let val = "before\0after"; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), + format!("SECRET=\"{}\"", common::enc(val)), + ], + ); + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + + // env mode: fails closed with a message about NUL. + let cfg_env = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, + keys.display(), + env.display() + ), + ); + let out = run_exec(&cfg_env, "true", &[]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("NUL"), "stderr: {stderr}"); + + // files mode: the exact bytes (including the NUL) reach the file. + let secrets_dir = d.join("secrets"); + std::fs::create_dir_all(&secrets_dir).unwrap(); + let cfg_files = write_config( + &d, + &format!( + r#"{{"mode":"files","keys":[{{"file":"{}"}}],"env_files":["{}"],"secrets_dir":"{}"}}"#, + keys.display(), + env.display(), + secrets_dir.display() + ), + ); + let sh = format!( + "cat '{sd}'/SECRET > '{d}'/out", + sd = secrets_dir.display(), + d = d.display() + ); + let out2 = run_exec(&cfg_files, &sh, &[]); + assert!( + out2.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out2.stderr) + ); + assert_eq!(std::fs::read(d.join("out")).unwrap(), val.as_bytes()); +} + +#[test] +fn exec_rejects_non_identifier_key_at_exec_boundary() { + let d = common::tmpdir("badkey"); + // A dotted key like BAD.KEY parses fine as dotenv but is_ident rejects it. + let env = common::write_env_file(&d, ".env", &["BAD.KEY=oops".to_string()]); + let keys = common::write_keys_file(&d, "", &[common::TEST_SK_HEX]); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, + keys.display(), + env.display() + ), + ); + let out = run_exec(&cfg, "true", &[]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("non-identifier"), + "expected 'non-identifier' in stderr, got: {stderr}" + ); +} diff --git a/tests/exec_conformance/files_mode.rs b/tests/exec_conformance/files_mode.rs new file mode 100644 index 0000000..906fc92 --- /dev/null +++ b/tests/exec_conformance/files_mode.rs @@ -0,0 +1,160 @@ +use std::process::Command; + +use crate::common; +use crate::{run_exec, write_config}; + +#[test] +fn files_mode_writes_0600_pointers_and_passthrough() { + let d = common::tmpdir("filesmode"); + let secrets_dir = d.join("secrets"); + std::fs::create_dir_all(&secrets_dir).unwrap(); + let val = "file-secret"; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), + format!("SECRET=\"{}\"", common::enc(val)), + "PLAIN=passthrough-val".to_string(), + ], + ); + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"files","keys":[{{"file":"{}"}}],"env_files":["{}"],"secrets_dir":"{}","passthrough":["PLAIN"]}}"#, + keys.display(), + env.display(), + secrets_dir.display() + ), + ); + let sh = format!( + r#"printf %s "$SECRET_FILE" > '{d}'/secret_file.out && \ + cat "$SECRET_FILE" > '{d}'/secret_contents.out && \ + printf %s "${{SECRET:-UNSET}}" > '{d}'/secret_var.out && \ + printf %s "$PLAIN" > '{d}'/plain.out && \ + printf %s "${{POSTMASTER_SECRETS_DIR:-UNSET}}" > '{d}'/secretsdir.out && \ + find "$SECRET_FILE" -perm 600 -print | grep -q ."#, + d = d.display() + ); + let out = run_exec(&cfg, &sh, &[]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + std::fs::read_to_string(d.join("secret_file.out")).unwrap(), + secrets_dir.join("SECRET").display().to_string() + ); + assert_eq!( + std::fs::read(d.join("secret_contents.out")).unwrap(), + val.as_bytes() + ); + assert_eq!(std::fs::read(d.join("secret_var.out")).unwrap(), b"UNSET"); + assert_eq!( + std::fs::read(d.join("plain.out")).unwrap(), + b"passthrough-val" + ); + assert_eq!( + std::fs::read_to_string(d.join("secretsdir.out")).unwrap(), + "UNSET" + ); + + // The 0600 check ran inside the service above (the file must be + // exactly 0600 while it exists). Once the service exits, the sibling + // monitor removes the plaintext (cleanup on every death path) — + // run_exec returning implies the monitor has already exited: it + // inherits postmaster's stdout/stderr pipe ends across the fork and + // output() only sees EOF after the monitor's cleanup has run. + assert!( + !secrets_dir.join("SECRET").exists(), + "the sibling monitor must remove plaintext files on service death" + ); +} + +#[test] +fn credentials_mode_systemd_pointers() { + let d = common::tmpdir("credsystemd"); + let creds_dir = d.join("creds"); + std::fs::create_dir_all(&creds_dir).unwrap(); + std::fs::write(creds_dir.join("DB_URL"), "abc").unwrap(); + let cfg = write_config(&d, r#"{"mode":"credentials","credential_keys":["DB_URL"]}"#); + let sh = format!( + "printf %s \"$DB_URL_FILE\" > '{d}'/dbfile.out && cat \"$DB_URL_FILE\" > '{d}'/dbcontents.out", + d = d.display() + ); + let out = run_exec( + &cfg, + &sh, + &[("CREDENTIALS_DIRECTORY", creds_dir.to_str().unwrap())], + ); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + std::fs::read_to_string(d.join("dbfile.out")).unwrap(), + creds_dir.join("DB_URL").display().to_string() + ); + assert_eq!(std::fs::read(d.join("dbcontents.out")).unwrap(), b"abc"); + + // Without CREDENTIALS_DIRECTORY, the systemd path has nothing to point + // at: fail closed. Explicitly remove from child to ensure hermeticity. + let mut c = Command::new(env!("CARGO_BIN_EXE_postmaster")); + c.args(["exec", "--config"]) + .arg(&cfg) + .args(["--", "/bin/sh", "-c", "true"]) + .env_remove("CREDENTIALS_DIRECTORY"); + let out2 = c.output().unwrap(); + assert!(!out2.status.success()); +} + +#[test] +fn credentials_mode_fetch_loop() { + let d = common::tmpdir("credfetch"); + let secrets_dir = d.join("secrets"); + std::fs::create_dir_all(&secrets_dir).unwrap(); + let sock = d.join("k.sock"); + let listener = std::os::unix::net::UnixListener::bind(&sock).unwrap(); + let server = std::thread::spawn(move || { + if let Ok((mut s, _)) = listener.accept() { + use std::io::Write; + let _ = s.write_all(b"s3cret"); + let _ = s.shutdown(std::net::Shutdown::Both); + } + }); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"credentials","credential_keys":["K"],"sockets":{{"K":"{}"}},"secrets_dir":"{}"}}"#, + sock.display(), + secrets_dir.display() + ), + ); + let sh = format!( + "printf %s \"$CREDENTIALS_DIRECTORY\" > '{d}'/creddir.out && cat '{sd}'/K > '{d}'/kcontents.out && find '{sd}/K' -perm 600 -print | grep -q .", + d = d.display(), + sd = secrets_dir.display() + ); + let out = run_exec(&cfg, &sh, &[]); + server.join().unwrap(); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + std::fs::read_to_string(d.join("creddir.out")).unwrap(), + secrets_dir.display().to_string() + ); + assert_eq!(std::fs::read(d.join("kcontents.out")).unwrap(), b"s3cret"); + // The 0600 check ran inside the service above; after the service + // exits the sibling monitor removes the plaintext (see the files-mode + // test for why run_exec returning implies the monitor has exited). + assert!( + !secrets_dir.join("K").exists(), + "the sibling monitor must remove plaintext files on service death" + ); +} diff --git a/tests/exec_conformance/keys_verify.rs b/tests/exec_conformance/keys_verify.rs new file mode 100644 index 0000000..fc43921 --- /dev/null +++ b/tests/exec_conformance/keys_verify.rs @@ -0,0 +1,268 @@ +use std::process::Command; + +use crate::common; +use crate::{run_exec, write_config}; + +#[test] +fn rotation_second_key_decrypts() { + let d = common::tmpdir("rotation"); + let val = "rotated-secret"; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), + format!("SECRET=\"{}\"", common::enc(val)), + ], + ); + let bogus = "22".repeat(32); + let keys = common::write_keys_file(&d, "PRODUCTION", &[&bogus, common::TEST_SK_HEX]); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"]}}"#, + keys.display(), + env.display() + ), + ); + let sh = format!("printf %s \"$SECRET\" > '{d}'/out", d = d.display()); + let out = run_exec(&cfg, &sh, &[]); + assert!( + out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(std::fs::read(d.join("out")).unwrap(), val.as_bytes()); +} + +#[test] +fn strict_failure_refuses_exec() { + let d = common::tmpdir("strictfail"); + // Encrypt with a DIFFERENT key than the one in our keyring, so + // decryption must fail even though the ciphertext is well-formed. + let other_sk_hex = "22".repeat(32); + let other_sk_bytes = hex::decode(&other_sk_hex).unwrap(); + let other_sk = ecies::SecretKey::parse_slice(&other_sk_bytes).unwrap(); + let other_pk = ecies::PublicKey::from_secret_key(&other_sk); + let ct = ecies::encrypt(&other_pk.serialize_compressed(), b"unreachable").unwrap(); + let b64 = { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(ct) + }; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!( + "DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", + hex::encode(other_pk.serialize_compressed()) + ), + format!("SECRET=\"encrypted:{b64}\""), + ], + ); + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"strict":true}}"#, + keys.display(), + env.display() + ), + ); + let sh = format!("printf %s \"$SECRET\" > '{d}'/out", d = d.display()); + let out = run_exec(&cfg, &sh, &[]); + assert!(!out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("no key under"), "stderr: {stderr}"); + assert!(!d.join("out").exists()); +} + +#[test] +fn strict_false_skips_undecryptable_and_continues() { + let d = common::tmpdir("laxskip"); + // GOOD decrypts with TEST_SK_HEX; BAD uses a different key so it fails. + let other_sk_hex = "33".repeat(32); + let other_sk_bytes = hex::decode(&other_sk_hex).unwrap(); + let other_sk = ecies::SecretKey::parse_slice(&other_sk_bytes).unwrap(); + let other_pk = ecies::PublicKey::from_secret_key(&other_sk); + let bad_ct = ecies::encrypt(&other_pk.serialize_compressed(), b"unreachable").unwrap(); + let bad_b64 = { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bad_ct) + }; + + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), + format!("GOOD=\"{}\"", common::enc("lax-ok")), + format!("BAD=\"encrypted:{bad_b64}\""), + ], + ); + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + // strict:false: BAD should be skipped (logged), GOOD should still reach the child. + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"strict":false}}"#, + keys.display(), + env.display() + ), + ); + let sh = format!( + "printf %s \"${{GOOD:-UNSET}}\" > '{d}'/good.out && printf %s \"${{BAD:-UNSET}}\" > '{d}'/bad.out", + d = d.display() + ); + let out = run_exec(&cfg, &sh, &[]); + assert!( + out.status.success(), + "strict:false should continue past undecryptable value and launch the child\nstderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!(std::fs::read(d.join("good.out")).unwrap(), b"lax-ok"); + // BAD was skipped: the child sees no BAD env var. + assert_eq!(std::fs::read(d.join("bad.out")).unwrap(), b"UNSET"); + // The stderr should mention the skip. + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("non-strict: skipping BAD"), + "expected skip log in stderr, got: {stderr}" + ); +} + +#[test] +fn verify_keys_aborts_on_stale_keys_with_strict_false() { + // Audit finding #4: --verify-keys should catch stale keys even when + // strict is false. Encrypt with a different key than the one in the + // keyring, set strict:false, and pass --verify-keys. The exec must + // abort with a --verify-keys error rather than launching the child + // with no secrets. + let d = common::tmpdir("verifykeys"); + let other_sk_hex = "33".repeat(32); + let other_sk_bytes = hex::decode(&other_sk_hex).unwrap(); + let other_sk = ecies::SecretKey::parse_slice(&other_sk_bytes).unwrap(); + let other_pk = ecies::PublicKey::from_secret_key(&other_sk); + let ct = ecies::encrypt(&other_pk.serialize_compressed(), b"stale-secret").unwrap(); + let b64 = { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(ct) + }; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!( + "DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", + hex::encode(other_pk.serialize_compressed()) + ), + format!("SECRET=\"encrypted:{b64}\""), + ], + ); + // Keyring has the TEST_SK_HEX key, NOT the other key — keys are stale. + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"strict":false}}"#, + keys.display(), + env.display() + ), + ); + // Without --verify-keys, strict:false would silently skip and succeed. + let sh = "true".to_string(); + let out_no_verify = run_exec(&cfg, &sh, &[]); + assert!( + out_no_verify.status.success(), + "without --verify-keys, strict:false should silently skip: {}", + String::from_utf8_lossy(&out_no_verify.stderr) + ); + + // With --verify-keys, the stale keys must be caught. + let mut c = Command::new(env!("CARGO_BIN_EXE_postmaster")); + c.args(["exec", "--config"]) + .arg(&cfg) + .args(["--verify-keys", "--", "true"]) + .env_remove("SECRET"); + let out = c.output().unwrap(); + assert!( + !out.status.success(), + "--verify-keys should abort on stale keys" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--verify-keys failed"), + "expected '--verify-keys failed' in stderr, got: {stderr}" + ); +} + +#[test] +fn verify_keys_aborts_on_partial_failure_with_strict_false() { + // Audit 2026-08-02 A3: with strict:false, resolution silently skips + // undecryptable values. When SOME values still decrypt, the resolved + // set is non-empty and the old freshness check passed — the child + // launched with a partial secret set. --verify-keys must now abort + // on any recorded skip. + let d = common::tmpdir("verifykeys-partial"); + let other_sk_hex = "33".repeat(32); + let other_sk_bytes = hex::decode(&other_sk_hex).unwrap(); + let other_sk = ecies::SecretKey::parse_slice(&other_sk_bytes).unwrap(); + let other_pk = ecies::PublicKey::from_secret_key(&other_sk); + let stale_ct = ecies::encrypt(&other_pk.serialize_compressed(), b"stale-secret").unwrap(); + let stale_b64 = { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(stale_ct) + }; + let env = common::write_env_file( + &d, + ".env.production", + &[ + format!("DOTENV_PUBLIC_KEY_PRODUCTION=\"{}\"", common::test_pk_hex()), + // Decrypts with the keyring's key — the resolved set is + // non-empty, which is what fooled the old check. + format!("SECRET_GOOD=\"{}\"", common::enc("good-secret")), + // Encrypted to a key NOT in the keyring — skipped under + // strict:false. This is the partial-rotation case. + format!("SECRET_STALE=\"encrypted:{stale_b64}\""), + ], + ); + let keys = common::write_keys_file(&d, "PRODUCTION", &[common::TEST_SK_HEX]); + let cfg = write_config( + &d, + &format!( + r#"{{"mode":"env","keys":[{{"file":"{}"}}],"env_files":["{}"],"strict":false}}"#, + keys.display(), + env.display() + ), + ); + // Without --verify-keys the partial set launches silently: + // SECRET_GOOD is served, SECRET_STALE is dropped. + let out_no_verify = run_exec(&cfg, "test \"$SECRET_GOOD\" = good-secret", &[]); + assert!( + out_no_verify.status.success(), + "without --verify-keys, strict:false should silently serve the partial set: {}", + String::from_utf8_lossy(&out_no_verify.stderr) + ); + + // With --verify-keys, the recorded skip must abort the launch. + let mut c = Command::new(env!("CARGO_BIN_EXE_postmaster")); + c.args(["exec", "--config"]) + .arg(&cfg) + .args(["--verify-keys", "--", "true"]) + .env_remove("SECRET_GOOD") + .env_remove("SECRET_STALE"); + let out = c.output().unwrap(); + assert!( + !out.status.success(), + "--verify-keys should abort on a partial decryption failure" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("--verify-keys failed"), + "expected '--verify-keys failed' in stderr, got: {stderr}" + ); + assert!( + stderr.contains("SECRET_STALE"), + "expected the skipped key name in stderr, got: {stderr}" + ); +} diff --git a/tests/exec_conformance/main.rs b/tests/exec_conformance/main.rs new file mode 100644 index 0000000..cc4d9a4 --- /dev/null +++ b/tests/exec_conformance/main.rs @@ -0,0 +1,89 @@ +#![allow(clippy::unwrap_used, clippy::panic, missing_docs)] + +mod bundle; +#[path = "../common/mod.rs"] +mod common; +mod env_mode; +mod files_mode; +mod keys_verify; + +use postmaster::adapter::KeyNaming; + +use std::path::Path; +use std::process::Command; + +#[test] +fn helper_ciphertext_decrypts_with_test_key() { + let v = common::enc("hello $(rm -rf /) `x` \"q\" \nline2"); + let b64 = v.strip_prefix("encrypted:").unwrap(); + let ct = { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(b64) + .unwrap() + }; + let sk = hex::decode(common::TEST_SK_HEX).unwrap(); + let pt = ecies::decrypt(&sk, &ct).unwrap(); + assert_eq!(pt, b"hello $(rm -rf /) `x` \"q\" \nline2"); +} + +#[test] +fn wrapped_base64_ciphertext_decrypts() { + // Test that decrypt_value tolerates ASCII whitespace in base64 payloads, + // matching dotenvx-rs's lenient decoding behavior. + let plaintext = "wrapped-value"; + let v = common::enc(plaintext); + let b64 = v.strip_prefix("encrypted:").unwrap(); + + // Wrap the base64 by inserting newlines every 20 chars + let mut wrapped = String::from("encrypted:"); + for (i, c) in b64.chars().enumerate() { + if i > 0 && i % 20 == 0 { + wrapped.push('\n'); + } + wrapped.push(c); + } + wrapped.push('\n'); + + // Create a temporary keys file with TEST_SK_HEX + let d = common::tmpdir("wrapped-base64"); + common::write_keys_file(&d, "", &[common::TEST_SK_HEX]); + let keys_path = d.join(".env.keys"); + + // Load the key ring + let ring = postmaster::keys::KeyRing::load(&keys_path, &[KeyNaming::Env]).unwrap(); + + // Create a dummy env file path for error messages + let dummy_env = d.join(".env"); + + // decrypt_value should handle the wrapped (whitespace-containing) payload + let result = + postmaster::keys::decrypt_value(&dummy_env, "TEST_VAR", &wrapped, &ring, KeyNaming::Env); + + assert!(result.is_ok(), "wrapped base64 should decrypt successfully"); + let decrypted = result.unwrap(); + assert_eq!(decrypted.as_slice(), plaintext.as_bytes()); +} + +fn run_exec(config: &Path, sh: &str, envs: &[(&str, &str)]) -> std::process::Output { + let mut c = Command::new(env!("CARGO_BIN_EXE_postmaster")); + c.args(["exec", "--config"]) + .arg(config) + .args(["--", "/bin/sh", "-c", sh]) + // Hermeticity: an ambient SECRET/PLAIN/A in the test runner's own + // environment would flip the precedence-test outcomes below, since + // these are exactly the value-variable names the suite asserts on. + .env_remove("SECRET") + .env_remove("PLAIN") + .env_remove("A"); + for (k, v) in envs { + c.env(k, v); + } + c.output().unwrap() +} + +fn write_config(dir: &Path, json: &str) -> std::path::PathBuf { + let p = dir.join("exec.json"); + std::fs::write(&p, json).unwrap(); + p +}