diff --git a/AGENTS.md b/AGENTS.md index 13f6e9e..04ceca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Inspect the existing implementation and tests before proposing changes. Do not i - Gitveil is one Rust 2024 package with a library and a thin binary. - Supported production hosts are macOS and Linux. - The Rust version is pinned in `rust-toolchain.toml`. -- The compatible SOPS build and artifact checksums are declared in `scripts/sops_artifacts.py`. +- Compatible SOPS and age-keygen builds and artifact checksums are declared in `scripts/sops_artifacts.py` and `scripts/age_artifacts.py`. - `Cargo.toml` intentionally has `publish = false`; do not publish the crate or create releases unless explicitly requested. - Keep all tracked text, code comments, tests, commit messages, and pull request content in English. - Do not add private keys, plaintext secrets, local absolute paths, generated build output, unpublished planning material, or tool transcripts. @@ -32,8 +32,8 @@ Keep pure policy and transformation code separate from external effects. - `scripts/check-architecture.py` is the executable declaration of modules that belong to the functional core. Add new pure modules to that declaration rather than bypassing the check. - `src/source/` owns typed and lossless source parsing and rendering. - `src/semantic/` owns pure semantic diff and merge behavior. -- `src/git/`, `src/sops/`, and `src/runtime/` contain external adapters. -- Top-level feature modules such as `configure`, `seal`, `open`, `status`, `verify`, and `resolve` orchestrate pure decisions and explicit effects. +- `src/git/`, `src/sops/`, `src/age.rs`, and `src/runtime/` contain external adapters. +- Top-level feature modules such as `identity`, `configure`, `seal`, `open`, `status`, `verify`, and `resolve` orchestrate pure decisions and explicit effects. - `src/main.rs` remains a thin CLI entry point; reusable behavior belongs in the library. Choose the correct abstraction rather than the smallest diff. Do not duplicate policy in an orchestrator when it belongs in a pure model or plan. @@ -45,10 +45,10 @@ Treat these as product contracts: - Secret values, private identities, data keys, and decrypted comments must not enter argv, Git metadata, logs, errors, reports, baselines, or tracked fixtures. - Secret-bearing values must not gain `Debug` or `Display` implementations that can reveal their contents. - Unknown Git or SOPS stderr is redacted by default. Preserve typed error categories instead of forwarding arbitrary subprocess output. -- SOPS subprocess arguments never contain secret values. +- SOPS and age-keygen subprocess arguments never contain secret values or private identity material. - Git subprocesses use read-only plumbing. Gitveil never modifies the index, Git configuration, hooks, attributes, or Git state outside `.git/gitveil/`. - Workspace paths are validated before descriptor-relative filesystem access. Symlink and submodule escapes must fail closed. -- Owner-only permissions are required for plaintext, runtime directories, temporary files, local IPC, and installed private sidecars where applicable. +- Owner-only permissions are required for plaintext, generated identities, identity staging, runtime directories, temporary files, local IPC, and installed private sidecars where applicable. - Production code must not use `unwrap` or `expect` for external input, filesystem, Git, SOPS, process, or protocol results. - Do not add `unsafe` code. If a platform security API has no safe wrapper, obtain maintainer approval, document the local invariant, and add platform coverage first. - Do not weaken a security assertion to accommodate nondeterministic fixture behavior. Isolate the external source of nondeterminism instead. @@ -72,7 +72,7 @@ Match the test level to the contract: - Keep pure decision tests colocated with their modules. - Use contract tests for public domain behavior. -- Use real ephemeral repositories and real pinned SOPS executables for Git, SOPS, filesystem, process, and runtime integration behavior. +- Use real ephemeral repositories and real pinned SOPS and age-keygen executables for Git, SOPS, identity, filesystem, process, and runtime integration behavior. - Inject failures only at external boundaries that cannot be triggered reliably; do not mock the semantic core. - Generate unique age identities and plaintext canaries at test time. Never add a private identity or plaintext secret fixture. - Assert unchanged ciphertext and encrypted leaves byte-for-byte where stability is part of the contract. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c9a79a..5f290ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,9 +35,10 @@ Preserve these invariants: - Unknown subprocess output is redacted by default. - Workspace paths are validated before descriptor-relative filesystem access; symlink and submodule escapes are rejected. - Git subprocesses are read-only. Gitveil does not modify the index, configuration, hooks, attributes, or Git state outside `.git/gitveil/`. -- SOPS subprocess arguments never contain secret values. +- SOPS and age-keygen subprocess arguments never contain secret values or private identity material. - Production code does not use `unwrap` or `expect` for external input, filesystem, Git, SOPS, process, or protocol results. - Do not use `unsafe` unless a platform security API has no safe wrapper; document the local invariant and add a platform test. +- Identity generation uses the pinned official age-keygen sidecar, stages private material in owner-only storage, and never replaces an existing identity path. - Tests generate private identities and plaintext canaries at runtime instead of storing them in tracked fixtures. - Unchanged ciphertext and encrypted leaves are asserted byte-for-byte where stability is part of the contract. @@ -61,7 +62,7 @@ On macOS, run both through the unified gate before requesting review: ./scripts/check-all.sh ``` -The gates cover formatting, architecture boundaries, public documentation links, Clippy, unit and integration tests, dependency policy, release builds, and native archive validation. Integration tests use real Git and checksum-pinned SOPS executables. Initial cold runs download dependencies and build artifacts and may take substantially longer than warm runs. +The gates cover formatting, architecture boundaries, public documentation links, Clippy, unit and integration tests, dependency policy, release builds, and native archive validation. Integration tests use real Git and checksum-pinned SOPS and age-keygen executables. Initial cold runs download dependencies and build artifacts and may take substantially longer than warm runs. GitHub Actions runs `scripts/check-host.sh` natively on Linux and macOS for every pull request and push to `main`. CI uses no repository secrets and does not publish artifacts or releases. diff --git a/README.md b/README.md index 6d571da..e32b47c 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,8 @@ Gitveil provides file-level encryption for dotenv, JSON, YAML, and TOML secret f - macOS or Linux - Git 2.20+ for `verify` and `resolve` - A matching age identity on machines that need plaintext access -- An external `age-keygen` executable for initial identity generation; existing identities and keyless commands do not require it -Gitveil itself does not require a system SOPS or age executable. Release archives include a checksum-verified official SOPS 3.13.3 sidecar at `libexec/gitveil/sops`; Gitveil does not search for SOPS on the system `PATH`. +Gitveil does not require a system SOPS or age executable. Release archives include checksum-verified official SOPS 3.13.3 and age-keygen 1.3.1 sidecars under `libexec/gitveil/`; Gitveil does not search for either tool on the system `PATH`. ## Installation @@ -27,7 +26,7 @@ The default prefix is `${CARGO_HOME:-$HOME/.cargo}`. To select another prefix: ./scripts/install-local.py --prefix ~/.local ``` -The installer performs a locked release build, fetches and verifies official SOPS 3.13.3, validates the complete release archive, and transactionally installs the binary, private sidecar, and license files. Do not use `cargo install --path .`: Cargo installs only the binary and omits the private sidecar. +The installer performs a locked release build, fetches and verifies official SOPS 3.13.3 and age-keygen 1.3.1 artifacts, validates the complete release archive, and transactionally installs the binary, private sidecars, and license files. Do not use `cargo install --path .`: Cargo installs only the binary and omits the private sidecars. The user-facing installation unit is a native release archive. Maintainers build one on each target platform with: @@ -40,37 +39,44 @@ The archive is written to `dist/gitveil-v--.tar.gz` with this ```text bin/gitveil libexec/gitveil/sops +libexec/gitveil/age-keygen share/licenses/gitveil/LICENSE share/licenses/gitveil/SOPS-MPL-2.0.txt share/licenses/gitveil/SOPS-NOTICE.txt +share/licenses/gitveil/AGE-BSD-3-Clause.txt +share/licenses/gitveil/AGE-NOTICE.txt ``` Install all three top-level directories under the same prefix and place `/bin` on `PATH`. Gitveil never downloads executables at runtime. -Source development and tests may explicitly override the private sidecar with `SOPS_BIN`; the override must still be SOPS 3.13.3. This variable is not part of the user installation contract. +Source development and tests may explicitly override the private sidecars with `SOPS_BIN` and `AGE_KEYGEN_BIN`; each override must still have its pinned version. These variables are not part of the user installation contract. ## Initial setup -Use an external `age-keygen` once to generate a private identity and derive its public recipient. Run these commands only when the identity file does not already exist: +Generate a private identity explicitly after installation. The command selects `SOPS_AGE_KEY_FILE` when set, then the standard SOPS identity file for the current platform, creates every missing identity directory at mode `0700`, writes the identity at mode `0600`, and never replaces an existing path: ```bash -umask 077 -identity_file="$HOME/.config/sops/age/keys.txt" -mkdir -p "$(dirname "$identity_file")" -age-keygen -o "$identity_file" -chmod 600 "$identity_file" - -export SOPS_AGE_KEY_FILE="$identity_file" -recipient="$(age-keygen -y "$SOPS_AGE_KEY_FILE")" +gitveil identity generate +recipient="$(gitveil identity recipients)" ``` +If a native age identity file already exists at the selected location, skip `generate` and use `gitveil identity recipients` to derive its public value. Both commands select one file using an explicit command option, then `SOPS_AGE_KEY_FILE`, then the platform default key file. `recipients` does not inspect `SOPS_AGE_KEY`, execute `SOPS_AGE_KEY_CMD`, or enumerate every identity source that SOPS may load. To use a custom location, select it consistently for generation and subsequent commands: + +```bash +export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" +gitveil identity generate +recipient="$(gitveil identity recipients)" +``` + +After the identity has been atomically published, a directory-sync failure is reported as a warning rather than a generation failure: the command still prints the published path and recipient and exits successfully, but durability across an immediate system crash was not confirmed. Preserve the identity file; `gitveil identity recipients --identity ` can print its recipient again. + `init` and `add` must run at a Git repository root containing a `.git` file or directory. `init` accepts only public `age1...` recipients, never private identities: ```bash gitveil init --policy team --recipient "$recipient" ``` -After installing `age-keygen` and Gitveil in a source checkout, run the isolated end-to-end example to exercise identity generation, `init`, `add`, `seal`, commit, clone, `open`, `status`, and `verify` with real command output: +From a source checkout, run the isolated end-to-end example to exercise Gitveil-managed identity generation, `init`, `add`, `seal`, commit, clone, `open`, `status`, and `verify` with real command output: ```bash ./examples/quickstart.sh @@ -117,7 +123,7 @@ Gitveil does not currently provide a managed-entry removal command. Manually rem ## Identity management -Gitveil does not generate, store, or synchronize private identities. Keep identity files at mode `0600`, back them up securely outside the repository, and have each team member generate an independent identity. Exchange only public recipients, never private keys. The repository stores only public `age1...` recipients. `status` and `verify` remain available without an identity. To add a member, add the member's public recipient to the manifest policy and run `gitveil seal` to rewrap access. +Gitveil generates native age identities only when `gitveil identity generate` is explicitly invoked. It does not overwrite, synchronize, distribute, back up, or automatically rotate them. Keep identity files at mode `0600`, back them up securely outside the repository, and have each team member generate an independent identity. Exchange only public recipients, never private keys. The repository stores only public `age1...` recipients. `status` and `verify` remain available without an identity. To add a member, add the member's public recipient to the manifest policy and run `gitveil seal` to rewrap access. To remove a member, first rotate the actual secret values in plaintext, remove the member's recipient from the manifest policy, and run `gitveil seal`. When Gitveil detects a recipient removal, it encrypts the entire file under a fresh data key and reports `sealed; data key rotated`; neither the new values nor the new key are decryptable by the removed identity. Addition-only changes keep the existing data key and preserve encrypted leaf bytes. @@ -175,7 +181,8 @@ Gitveil reads index stages but never runs `git add`. - Gitveil enforces SOPS `encrypted_regex: .*`; every source scalar and layout/comment value is encrypted. - Each ciphertext file uses one data key. Removing a recipient causes `seal` and `resolve` to rotate that key, preventing the removed identity from decrypting later versions. Access to historical versions cannot be revoked; forward exclusion also requires rotating the actual secret values. - Paths undergo workspace-relative validation and descriptor-relative confinement; symlink and submodule escapes are rejected. -- SOPS subprocesses have deadlines, and temporary files and IPC endpoints live in owner-only runtime directories. +- SOPS and age-keygen subprocesses have deadlines. Unknown sidecar stderr is redacted and converted into fixed, typed diagnostics rather than being forwarded verbatim. +- Temporary files and IPC endpoints live in owner-only runtime directories. - Gitveil writes no Git-internal state outside `.git/gitveil/` and never modifies the index, configuration, hooks, or attributes. Only root-level `init` and `add` write repository configuration; `add` owns and verifies the managed `.gitignore` block. ## Development @@ -190,4 +197,4 @@ The unified local quality gate covers the macOS host, Docker Linux, real SOPS co ## License -Gitveil source code is available under the [MIT License](LICENSE). The official SOPS executable bundled in native release archives is distributed under the [Mozilla Public License 2.0](licenses/SOPS-MPL-2.0.txt); its version and corresponding source location are recorded in the [SOPS notice](licenses/SOPS-NOTICE.txt). +Gitveil source code is available under the [MIT License](LICENSE). The official SOPS executable bundled in native release archives is distributed under the [Mozilla Public License 2.0](licenses/SOPS-MPL-2.0.txt); its version and source location are recorded in the [SOPS notice](licenses/SOPS-NOTICE.txt). The bundled official age-keygen executable is distributed under the [BSD 3-Clause License](licenses/AGE-BSD-3-Clause.txt); its version and source location are recorded in the [age notice](licenses/AGE-NOTICE.txt). diff --git a/SECURITY.md b/SECURITY.md index ec261a3..86843ca 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -34,7 +34,8 @@ Reports are especially valuable when they involve: - Unauthorized decryption after recipient removal or incorrect recipient-policy enforcement. - Failure to rotate the data key when a recipient is removed. - Modification of the Git index, configuration, hooks, attributes, or state outside `.git/gitveil/`. -- SOPS sidecar substitution, checksum bypass, version confusion, or repository configuration injection. +- SOPS or age-keygen sidecar substitution, checksum bypass, version confusion, or repository configuration injection. +- Identity generation that overwrites an existing path, leaves partial private material, uses unsafe permissions, or reveals a private identity. - Local IPC authentication, permissions, process cleanup, or lock isolation failures. - Ciphertext integrity, MAC, merge, or rollback behavior that can expose or silently replace secret data. diff --git a/docker/test.Dockerfile b/docker/test.Dockerfile index 8d88e05..8e10e1a 100644 --- a/docker/test.Dockerfile +++ b/docker/test.Dockerfile @@ -12,7 +12,7 @@ ARG SOPS_SHA256 ARG SOPS_BASELINE_VERSION=3.13.2 ARG SOPS_BASELINE_ARTIFACT ARG SOPS_BASELINE_SHA256 -# age-keygen is a test-only provisioning dependency for the public quickstart. +# age-keygen is the pinned identity sidecar used by integration and packaging tests. ARG AGE_VERSION=1.3.1 ARG AGE_ARTIFACT ARG AGE_SHA256 @@ -20,7 +20,8 @@ ARG NEXTEST_VERSION=0.9.140 ENV SOPS_BIN=/usr/local/bin/sops \ SOPS_BASELINE_BIN=/usr/local/bin/sops-baseline \ SOPS_DISABLE_VERSION_CHECK=1 \ - AGE_KEYGEN_BIN=/usr/local/bin/age-keygen + AGE_KEYGEN_BIN=/usr/local/bin/age-keygen \ + AGE_KEYGEN_ARCHIVE=/usr/local/share/gitveil-test/age.tar.gz RUN apt-get update \ && apt-get install --yes --no-install-recommends ca-certificates curl git python3 \ @@ -61,6 +62,8 @@ RUN set -eu; \ echo "${AGE_SHA256} /tmp/age.tar.gz" | sha256sum --check --strict; \ tar -xzf /tmp/age.tar.gz -C /tmp age/age-keygen; \ install -m 0755 /tmp/age/age-keygen /usr/local/bin/age-keygen; \ + mkdir -p /usr/local/share/gitveil-test; \ + install -m 0644 /tmp/age.tar.gz /usr/local/share/gitveil-test/age.tar.gz; \ rm -rf /tmp/age /tmp/age.tar.gz; \ age-keygen --version diff --git a/docs/getting-started.md b/docs/getting-started.md index 53f961e..ad580f8 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,47 +1,38 @@ # Getting started -This guide starts by generating an age identity outside Gitveil, then exercises `init`, `add`, `seal`, commit, clone, `open`, `status`, and `verify` in a temporary Git repository. Gitveil does not generate, store, or synchronize private identities. +This guide starts by explicitly generating an age identity through Gitveil, then exercises `init`, `add`, `seal`, commit, clone, `open`, `status`, and `verify` in a temporary Git repository. Gitveil does not generate an identity during installation or without an explicit command. ## Prerequisites - Install the complete Gitveil distribution as described in the [`README.md`](../README.md); do not use only `cargo install --path .`. - Install Git 2.20+. -- Install [age](https://github.com/FiloSottile/age#installation), including `age-keygen`. -```bash -# macOS -brew install age +The complete distribution includes private, checksum-verified SOPS and age-keygen sidecars. Neither executable needs to be installed on the system `PATH`. -# Debian / Ubuntu -sudo apt install age +## Prepare an identity once -# Fedora -sudo dnf install age +The following commands create a long-lived local identity at the standard SOPS location for the current platform and derive its public recipient: -# Arch Linux -sudo pacman -S age +```bash +gitveil identity generate +recipient="$(gitveil identity recipients)" ``` -If none of these package managers applies, install the binary for your platform from an official age release and verify its checksum. `age-keygen` is needed only to generate an identity and derive its public recipient; normal Gitveil operation does not depend on a system age executable. - -## Prepare an identity once +Generation creates every missing identity directory at mode `0700`, writes the identity at mode `0600`, and refuses to replace any existing path. Installation itself never creates an identity. -The following commands create a long-lived local identity. Run them only when the destination does not already exist; never overwrite an existing identity: +To select a custom location, set it before both commands and keep that setting for commands that need plaintext access: ```bash -umask 077 -identity_file="$HOME/.config/sops/age/keys.txt" -mkdir -p "$(dirname "$identity_file")" -age-keygen -o "$identity_file" -chmod 600 "$identity_file" - -export SOPS_AGE_KEY_FILE="$identity_file" -recipient="$(age-keygen -y "$SOPS_AGE_KEY_FILE")" +export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt" +gitveil identity generate +recipient="$(gitveil identity recipients)" ``` -`recipient` is a public, committable `age1...` string. `SOPS_AGE_KEY_FILE` contains only the path to the identity file. The private identity itself must never enter a repository, command argument, log, or chat system. +`recipient` is a public, committable `age1...` string. Both identity commands select one file using an explicit command option, then `SOPS_AGE_KEY_FILE`, then the platform default key file. `gitveil identity recipients` does not inspect `SOPS_AGE_KEY`, execute `SOPS_AGE_KEY_CMD`, or enumerate every identity source that SOPS may load. + +`SOPS_AGE_KEY_FILE` contains only the path to the identity file. The private identity itself must never enter a repository, command argument, log, or chat system. Back it up securely outside the repository; plaintext cannot be recovered after every matching identity is lost. Gitveil redacts unknown sidecar stderr and reports fixed, classified diagnostics instead. -For regular use, set the `SOPS_AGE_KEY_FILE` path in your shell configuration. Keep the identity file at mode `0600` and back it up securely outside the repository. Plaintext cannot be recovered after every matching identity is lost. +If generation warns that directory durability could not be confirmed, the identity has already been published and the command still prints its public recipient. Preserve the identity file rather than rerunning generation; use `gitveil identity recipients --identity ` if the recipient must be printed again. A team should not share one private identity. Each member generates an independent identity and sends only the public recipient. Add multiple recipients to the same policy during initialization: @@ -121,13 +112,13 @@ A source checkout includes an isolated, repeatable script that performs the same ./examples/quickstart.sh ``` -The script uses an external `age-keygen` to create a temporary demo identity, isolates HOME and Git configuration, verifies recovery in the clone, and then removes the identity, plaintext, and both temporary repositories. It does not read or modify a long-lived identity and does not print the recipient, private identity, or demo plaintext. To inspect the generated source and clone repositories, explicitly keep a successful workspace: +The script uses `gitveil identity generate` with the pinned private age-keygen sidecar to create a temporary demo identity, isolates HOME and Git configuration, verifies recovery in the clone, and then removes the identity, plaintext, and both temporary repositories. It does not read or modify a long-lived identity and does not print the recipient, private identity, or demo plaintext. To inspect the generated source and clone repositories, explicitly keep a successful workspace: ```bash ./examples/quickstart.sh --keep-workspace ``` -A retained workspace contains an ephemeral private identity and demo plaintext. Use it only for local inspection, then delete the entire path printed by the script. The quickstart script is not included in release archives, which contain only the binary, private sidecar, and license files; archive users can follow the commands in this guide instead. +A retained workspace contains an ephemeral private identity and demo plaintext. Use it only for local inspection, then delete the entire path printed by the script. The quickstart script is not included in release archives, which contain the Gitveil binary, both private sidecars, and license files; archive users can follow the commands in this guide instead. ## Add Gitveil to an existing repository diff --git a/examples/quickstart.sh b/examples/quickstart.sh index 531387f..1b5f835 100755 --- a/examples/quickstart.sh +++ b/examples/quickstart.sh @@ -25,7 +25,7 @@ case "$#" in *) usage ;; esac -for dependency in git age-keygen mktemp cmp stat uname; do +for dependency in git mktemp cmp stat uname; do command -v "$dependency" >/dev/null 2>&1 || fail "$dependency is required" done command -v "$GITVEIL_BIN" >/dev/null 2>&1 || fail "gitveil is required" @@ -47,7 +47,6 @@ HOME_DIR=$WORK_ROOT/home SOURCE_REPOSITORY=$WORK_ROOT/source CLONED_REPOSITORY=$WORK_ROOT/clone IDENTITY_FILE=$WORK_ROOT/identity.txt -AGE_KEYGEN_STDERR=$WORK_ROOT/age-keygen.stderr GIT_LS_FILES_STDERR=$WORK_ROOT/git-ls-files.stderr mkdir -p "$HOME_DIR/xdg" "$SOURCE_REPOSITORY" : >"$HOME_DIR/gitconfig" @@ -129,21 +128,10 @@ compare_plaintext() { } step "Generating an ephemeral age identity" -if age-keygen -o "$IDENTITY_FILE" >/dev/null 2>"$AGE_KEYGEN_STDERR"; then - rm -f "$AGE_KEYGEN_STDERR" -else - status=$? - cat "$AGE_KEYGEN_STDERR" >&2 - exit "$status" -fi -chmod 600 "$IDENTITY_FILE" -if RECIPIENT=$(age-keygen -y "$IDENTITY_FILE" 2>"$AGE_KEYGEN_STDERR"); then - rm -f "$AGE_KEYGEN_STDERR" -else - status=$? - cat "$AGE_KEYGEN_STDERR" >&2 - exit "$status" -fi +"$GITVEIL_BIN" identity generate --output "$IDENTITY_FILE" >/dev/null || \ + fail "gitveil identity generation failed" +RECIPIENT=$("$GITVEIL_BIN" identity recipients --identity "$IDENTITY_FILE") || \ + fail "gitveil recipient derivation failed" [ -n "$RECIPIENT" ] || fail "public recipient derivation returned no recipient" export SOPS_AGE_KEY_FILE=$IDENTITY_FILE diff --git a/licenses/AGE-BSD-3-Clause.txt b/licenses/AGE-BSD-3-Clause.txt new file mode 100644 index 0000000..0080870 --- /dev/null +++ b/licenses/AGE-BSD-3-Clause.txt @@ -0,0 +1,29 @@ +Copyright 2019 The age Authors +Copyright 2019 Google LLC +Copyright 2022 Filippo Valsorda + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of the age project nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/licenses/AGE-NOTICE.txt b/licenses/AGE-NOTICE.txt new file mode 100644 index 0000000..da6e5c3 --- /dev/null +++ b/licenses/AGE-NOTICE.txt @@ -0,0 +1,5 @@ +Gitveil release archives redistribute an unmodified official age-keygen 1.3.1 executable. + +Project: FiloSottile/age +Source code: https://github.com/FiloSottile/age/tree/v1.3.1 +License: BSD 3-Clause License (see AGE-BSD-3-Clause.txt) diff --git a/scripts/age_artifacts.py b/scripts/age_artifacts.py index 47d1157..7eb69f1 100755 --- a/scripts/age_artifacts.py +++ b/scripts/age_artifacts.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Checksum-pinned official age artifacts used by the quickstart test contract.""" +"""Pinned official age-keygen artifacts shared by tests and release packaging.""" from __future__ import annotations @@ -129,6 +129,30 @@ def fetch_archive(destination: Path, artifact: Artifact) -> Path: return destination +def age_keygen_digest(archive: Path) -> str: + with tarfile.open(archive, "r:gz") as package: + try: + member = package.getmember("age/age-keygen") + except KeyError as error: + raise ValueError("official age archive does not contain age/age-keygen") from error + if not member.isfile(): + raise ValueError("official age archive age/age-keygen is not a file") + source = package.extractfile(member) + if source is None: + raise ValueError("official age archive age/age-keygen cannot be read") + return hashlib.sha256(source.read()).hexdigest() + + +def verify_binary(path: Path, archive: Path, artifact: Artifact) -> None: + verify_archive(archive, artifact) + expected = age_keygen_digest(archive) + actual = digest(path) + if actual != expected: + raise ValueError( + f"age-keygen checksum mismatch: expected {expected}, got {actual}" + ) + + def extract_verified_age_keygen(archive: Path, destination: Path) -> Path: with tarfile.open(archive, "r:gz") as package: try: @@ -156,18 +180,16 @@ def extract_verified_age_keygen(archive: Path, destination: Path) -> Path: def fetch_verified(destination: Path, artifact: Artifact) -> Path: archive = fetch_archive(destination.parent / artifact.filename, artifact) - with tarfile.open(archive, "r:gz") as package: - source = package.extractfile("age/age-keygen") - if source is None: - raise ValueError("official age archive age/age-keygen cannot be read") - expected_binary_digest = hashlib.sha256(source.read()).hexdigest() + expected_binary_digest = age_keygen_digest(archive) if destination.is_file() and digest(destination) == expected_binary_digest: destination.chmod(destination.stat().st_mode | stat.S_IXUSR) return destination extracted = extract_verified_age_keygen(archive, destination) - if digest(extracted) != expected_binary_digest: + try: + verify_binary(extracted, archive, artifact) + except ValueError: extracted.unlink(missing_ok=True) - raise ValueError("extracted age-keygen does not match the verified archive") + raise return extracted diff --git a/scripts/check-architecture.py b/scripts/check-architecture.py index b9bb5f1..cfdd82e 100644 --- a/scripts/check-architecture.py +++ b/scripts/check-architecture.py @@ -30,11 +30,14 @@ "src/resolve/plan.rs", "src/verify/policy.rs", "src/sops/classify.rs", + "src/age/classify.rs", ] FORBIDDEN = { + r"\bcrate::age\b": "age adapter", r"\bcrate::git\b": "Git adapter", r"\bcrate::sops\b": "SOPS adapter", r"\bcrate::runtime\b": "runtime adapter", + r"\bcrate::\s*\{[^}]*\bage\b": "age adapter", r"\bcrate::\s*\{[^}]*\bgit\b": "Git adapter", r"\bcrate::\s*\{[^}]*\bsops\b": "SOPS adapter", r"\bcrate::\s*\{[^}]*\bruntime\b": "runtime adapter", diff --git a/scripts/check-host.sh b/scripts/check-host.sh index 98bd343..3b122b0 100755 --- a/scripts/check-host.sh +++ b/scripts/check-host.sh @@ -19,6 +19,7 @@ fi SOPS_TEST_BIN=$(python3 scripts/fetch-test-sops.py) AGE_KEYGEN_TEST_BIN=$(python3 scripts/fetch-test-age.py) +AGE_KEYGEN_TEST_ARCHIVE=$(python3 scripts/fetch-test-age.py --print-archive) cargo fmt --all -- --check python3 scripts/check-architecture.py python3 scripts/check-docs.py @@ -30,5 +31,7 @@ python3 scripts/package-release.py \ --skip-build \ --binary target/release/gitveil \ --sops-bin "$SOPS_TEST_BIN" \ + --age-keygen-bin "$AGE_KEYGEN_TEST_BIN" \ + --age-keygen-archive "$AGE_KEYGEN_TEST_ARCHIVE" \ --output-dir target/package-test git diff --check diff --git a/scripts/check-linux.sh b/scripts/check-linux.sh index c7e3b3b..fb21805 100755 --- a/scripts/check-linux.sh +++ b/scripts/check-linux.sh @@ -64,5 +64,7 @@ docker run --rm \ --skip-build \ --binary target/release/gitveil \ --sops-bin /usr/local/bin/sops \ + --age-keygen-bin /usr/local/bin/age-keygen \ + --age-keygen-archive /usr/local/share/gitveil-test/age.tar.gz \ --output-dir target/package-test ' diff --git a/scripts/fetch-test-age.py b/scripts/fetch-test-age.py index 2ad9e70..fa15565 100755 --- a/scripts/fetch-test-age.py +++ b/scripts/fetch-test-age.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Fetch the checksum-pinned age-keygen binary used by quickstart tests.""" +"""Fetch the checksum-pinned age-keygen binary used by integration tests.""" from __future__ import annotations +import argparse from pathlib import Path import sys @@ -10,17 +11,22 @@ def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--print-archive", action="store_true") + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] destination = root / "target" / "test-tools" / f"age-{VERSION}" / "age-keygen" + artifact = artifact_for() try: - binary = fetch_verified(destination, artifact_for()) + binary = fetch_verified(destination, artifact) except ValueError as error: print(error, file=sys.stderr) return 2 except Exception as error: print(f"could not fetch the age-keygen test binary: {error}", file=sys.stderr) return 1 - print(binary) + print(binary.parent / artifact.filename if args.print_archive else binary) return 0 diff --git a/scripts/install-local.py b/scripts/install-local.py index d280f69..5b50915 100755 --- a/scripts/install-local.py +++ b/scripts/install-local.py @@ -18,7 +18,10 @@ ("share/licenses/gitveil/LICENSE", 0o644), ("share/licenses/gitveil/SOPS-MPL-2.0.txt", 0o644), ("share/licenses/gitveil/SOPS-NOTICE.txt", 0o644), + ("share/licenses/gitveil/AGE-BSD-3-Clause.txt", 0o644), + ("share/licenses/gitveil/AGE-NOTICE.txt", 0o644), ("libexec/gitveil/sops", 0o755), + ("libexec/gitveil/age-keygen", 0o755), ("bin/gitveil", 0o755), ) @@ -51,6 +54,7 @@ def extract_payload(archive: Path, archive_root: str, staging: Path) -> Path: def verify_payload(payload: Path) -> None: commands = [ [str(payload / "libexec/gitveil/sops"), "--disable-version-check", "--version"], + [str(payload / "libexec/gitveil/age-keygen"), "--version"], [str(payload / "bin/gitveil"), "--version"], ] for command in commands: @@ -125,7 +129,7 @@ def install_archive(archive: Path, archive_root: str, prefix: Path) -> None: def main() -> int: parser = argparse.ArgumentParser( - description="Build and install Gitveil with its pinned private SOPS sidecar." + description="Build and install Gitveil with its pinned private sidecars." ) parser.add_argument( "--prefix", @@ -134,6 +138,8 @@ def main() -> int: ) parser.add_argument("--binary", type=Path, help=argparse.SUPPRESS) parser.add_argument("--sops-bin", type=Path, help=argparse.SUPPRESS) + parser.add_argument("--age-keygen-bin", type=Path, help=argparse.SUPPRESS) + parser.add_argument("--age-keygen-archive", type=Path, help=argparse.SUPPRESS) parser.add_argument("--skip-build", action="store_true", help=argparse.SUPPRESS) args = parser.parse_args() @@ -142,11 +148,24 @@ def main() -> int: subprocess.run(["cargo", "build", "--release", "--locked"], cwd=root, check=True) binary = (args.binary or root / "target" / "release" / "gitveil").resolve() sops_binary = args.sops_bin.resolve() if args.sops_bin else None + age_keygen_binary = ( + args.age_keygen_bin.resolve() if args.age_keygen_bin else None + ) + age_keygen_archive = ( + args.age_keygen_archive.resolve() if args.age_keygen_archive else None + ) prefix = (args.prefix or default_prefix()).expanduser().resolve() archive_root = archive_root_name(root) with tempfile.TemporaryDirectory(prefix="gitveil-local-package-") as temporary: - archive = build_archive(root, Path(temporary), binary, sops_binary) + archive = build_archive( + root, + Path(temporary), + binary, + sops_binary, + age_keygen_binary, + age_keygen_archive, + ) install_archive(archive, archive_root, prefix) print(f"Installed Gitveil {package_version(root)} to {prefix}") diff --git a/scripts/package-release.py b/scripts/package-release.py index 9d4957a..93d2062 100644 --- a/scripts/package-release.py +++ b/scripts/package-release.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build a native Gitveil release archive with its pinned private SOPS sidecar.""" +"""Build a native Gitveil release archive with its pinned private sidecars.""" from __future__ import annotations @@ -16,6 +16,8 @@ def main() -> int: parser.add_argument("--output-dir", type=Path, default=Path("dist")) parser.add_argument("--binary", type=Path) parser.add_argument("--sops-bin", type=Path) + parser.add_argument("--age-keygen-bin", type=Path) + parser.add_argument("--age-keygen-archive", type=Path) parser.add_argument("--skip-build", action="store_true") args = parser.parse_args() @@ -24,8 +26,21 @@ def main() -> int: subprocess.run(["cargo", "build", "--release", "--locked"], cwd=root, check=True) binary = (args.binary or root / "target" / "release" / "gitveil").resolve() sops_binary = args.sops_bin.resolve() if args.sops_bin else None + age_keygen_binary = ( + args.age_keygen_bin.resolve() if args.age_keygen_bin else None + ) + age_keygen_archive = ( + args.age_keygen_archive.resolve() if args.age_keygen_archive else None + ) output_dir = args.output_dir if args.output_dir.is_absolute() else root / args.output_dir - archive = build_archive(root, output_dir, binary, sops_binary) + archive = build_archive( + root, + output_dir, + binary, + sops_binary, + age_keygen_binary, + age_keygen_archive, + ) checksum = digest(archive) checksum_file = archive.with_suffix(f"{archive.suffix}.sha256") checksum_file.write_text(f"{checksum} {archive.name}\n", encoding="utf-8") diff --git a/scripts/release_package.py b/scripts/release_package.py index 7a7b21c..6a66561 100644 --- a/scripts/release_package.py +++ b/scripts/release_package.py @@ -12,6 +12,10 @@ import tarfile import tempfile +from age_artifacts import VERSION as AGE_VERSION +from age_artifacts import artifact_for as age_artifact_for +from age_artifacts import fetch_verified as fetch_age_keygen +from age_artifacts import verify_binary as verify_age_keygen from sops_artifacts import VERSION as SOPS_VERSION from sops_artifacts import artifact_for, fetch_verified, verify @@ -85,12 +89,15 @@ def expected_archive_members(archive_root: str) -> set[str]: f"{archive_root}/libexec", f"{archive_root}/libexec/gitveil", f"{archive_root}/libexec/gitveil/sops", + f"{archive_root}/libexec/gitveil/age-keygen", f"{archive_root}/share", f"{archive_root}/share/licenses", f"{archive_root}/share/licenses/gitveil", f"{archive_root}/share/licenses/gitveil/LICENSE", f"{archive_root}/share/licenses/gitveil/SOPS-MPL-2.0.txt", f"{archive_root}/share/licenses/gitveil/SOPS-NOTICE.txt", + f"{archive_root}/share/licenses/gitveil/AGE-BSD-3-Clause.txt", + f"{archive_root}/share/licenses/gitveil/AGE-NOTICE.txt", } @@ -109,16 +116,23 @@ def validate_archive(archive: Path, archive_root: str) -> None: files = { f"{archive_root}/bin/gitveil", f"{archive_root}/libexec/gitveil/sops", + f"{archive_root}/libexec/gitveil/age-keygen", f"{archive_root}/share/licenses/gitveil/LICENSE", f"{archive_root}/share/licenses/gitveil/SOPS-MPL-2.0.txt", f"{archive_root}/share/licenses/gitveil/SOPS-NOTICE.txt", + f"{archive_root}/share/licenses/gitveil/AGE-BSD-3-Clause.txt", + f"{archive_root}/share/licenses/gitveil/AGE-NOTICE.txt", } for name, member in members.items(): if name in files and not member.isfile(): raise RuntimeError(f"release member is not a file: {name}") if name not in files and not member.isdir(): raise RuntimeError(f"release member is not a directory: {name}") - for executable in [f"{archive_root}/bin/gitveil", f"{archive_root}/libexec/gitveil/sops"]: + for executable in [ + f"{archive_root}/bin/gitveil", + f"{archive_root}/libexec/gitveil/sops", + f"{archive_root}/libexec/gitveil/age-keygen", + ]: if members[executable].mode & 0o111 == 0: raise RuntimeError(f"release executable is not executable: {executable}") @@ -128,6 +142,8 @@ def build_archive( output_dir: Path, binary: Path, sops_binary: Path | None, + age_keygen_binary: Path | None, + age_keygen_archive: Path | None, ) -> Path: artifact = artifact_for() if not binary.is_file(): @@ -140,12 +156,28 @@ def build_archive( else: verify(sops_binary, artifact) + age_artifact = age_artifact_for() + age_tools = root / "target" / "release-tools" / f"age-{AGE_VERSION}" + if age_keygen_binary is None: + if age_keygen_archive is not None: + raise RuntimeError( + "--age-keygen-bin is required with --age-keygen-archive" + ) + age_keygen_binary = fetch_age_keygen(age_tools / "age-keygen", age_artifact) + else: + if age_keygen_archive is None: + raise RuntimeError( + "--age-keygen-archive is required with --age-keygen-bin" + ) + verify_age_keygen(age_keygen_binary, age_keygen_archive, age_artifact) + archive_root = archive_root_name(root) with tempfile.TemporaryDirectory(prefix="gitveil-package-") as temporary: staging_root = Path(temporary) / archive_root destinations = { binary: staging_root / "bin" / "gitveil", sops_binary: staging_root / "libexec" / "gitveil" / "sops", + age_keygen_binary: staging_root / "libexec" / "gitveil" / "age-keygen", root / "LICENSE": staging_root / "share" / "licenses" / "gitveil" / "LICENSE", root / "licenses" / "SOPS-MPL-2.0.txt": staging_root / "share" @@ -157,6 +189,16 @@ def build_archive( / "licenses" / "gitveil" / "SOPS-NOTICE.txt", + root / "licenses" / "AGE-BSD-3-Clause.txt": staging_root + / "share" + / "licenses" + / "gitveil" + / "AGE-BSD-3-Clause.txt", + root / "licenses" / "AGE-NOTICE.txt": staging_root + / "share" + / "licenses" + / "gitveil" + / "AGE-NOTICE.txt", } for source, destination in destinations.items(): if not source.is_file(): @@ -165,6 +207,7 @@ def build_archive( shutil.copyfile(source, destination) os.chmod(staging_root / "bin" / "gitveil", 0o755) os.chmod(staging_root / "libexec" / "gitveil" / "sops", 0o755) + os.chmod(staging_root / "libexec" / "gitveil" / "age-keygen", 0o755) archive = output_dir / f"{archive_root}.tar.gz" write_archive(staging_root, archive) validate_archive(archive, archive_root) diff --git a/src/age.rs b/src/age.rs new file mode 100644 index 0000000..aa7c0e3 --- /dev/null +++ b/src/age.rs @@ -0,0 +1,168 @@ +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitStatus}; +use std::time::Duration; + +use semver::Version; + +use crate::error::{ErrorCategory, GitveilError, Result}; +use crate::recipient::AgeRecipient; +use crate::runtime::{packaged_sidecar_path, run_capture}; + +mod classify; + +use classify::{ + GenerationFailure, RecipientFailure, classify_generation, classify_recipient, parse_recipients, +}; + +pub(crate) const AGE_KEYGEN_VERSION: (u64, u64, u64) = (1, 3, 1); +const PROCESS_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug)] +pub(crate) struct AgeKeygenBinary { + path: PathBuf, +} + +impl AgeKeygenBinary { + pub(crate) fn discover(gitveil_binary: &Path) -> Result { + let packaged = packaged_sidecar_path(gitveil_binary, "age-keygen")?; + let path = if let Some(configured) = std::env::var_os("AGE_KEYGEN_BIN") { + let configured = PathBuf::from(configured); + if configured.as_os_str().is_empty() { + return Err(GitveilError::dependency("AGE_KEYGEN_BIN cannot be empty")); + } + if !configured.is_file() { + return Err(GitveilError::dependency(format!( + "AGE_KEYGEN_BIN does not point to a file: {}", + configured.display() + ))); + } + configured + } else if packaged.is_file() { + packaged + } else { + return Err(GitveilError::dependency(format!( + "packaged age-keygen sidecar was not found at {}; reinstall Gitveil", + packaged.display() + ))); + }; + let binary = Self { path }; + binary.verify_version()?; + Ok(binary) + } + + pub(crate) fn generate(&self, output: &Path) -> Result<()> { + let mut command = Command::new(&self.path); + command.arg("-o").arg(output); + let result = run_capture(command, None, PROCESS_TIMEOUT, "age-keygen")?; + if !result.status.success() { + let error = match classify_generation(result.stderr.as_slice()) { + GenerationFailure::NoSpace => GitveilError::new( + ErrorCategory::Io, + "age-keygen could not write the identity: no space left on device", + ), + GenerationFailure::PermissionDenied => GitveilError::new( + ErrorCategory::Io, + "age-keygen could not write the identity: permission denied", + ), + GenerationFailure::ReadOnlyFilesystem => GitveilError::new( + ErrorCategory::Io, + "age-keygen could not write the identity: read-only file system", + ), + GenerationFailure::OutputIo => GitveilError::new( + ErrorCategory::Io, + "age-keygen could not write the identity: input/output failure", + ), + GenerationFailure::Execution => { + process_exit_error("age-keygen identity generation failed", result.status) + } + }; + return Err(error); + } + Ok(()) + } + + pub(crate) fn recipients(&self, identity: &Path) -> Result> { + let mut command = Command::new(&self.path); + command.arg("-y").arg(identity); + let output = run_capture(command, None, PROCESS_TIMEOUT, "age-keygen")?; + if !output.status.success() { + let error = match classify_recipient(output.stderr.as_slice()) { + RecipientFailure::IdentityUnavailable => GitveilError::new( + ErrorCategory::IdentityUnavailable, + "age-keygen could not derive recipients from the selected identity file", + ), + RecipientFailure::InvalidIdentity => GitveilError::new( + ErrorCategory::IdentityUnavailable, + "selected identity file is not a valid native age identity", + ), + RecipientFailure::Protocol => GitveilError::new( + ErrorCategory::Protocol, + "age-keygen could not represent a native identity as a public recipient", + ), + RecipientFailure::Execution => { + process_exit_error("age-keygen recipient derivation failed", output.status) + } + }; + return Err(error); + } + parse_recipients(output.stdout.as_slice()) + } + + fn verify_version(&self) -> Result<()> { + let mut command = Command::new(&self.path); + command.arg("--version"); + let output = run_capture(command, None, Duration::from_secs(15), "age-keygen") + .map_err(classify_dependency_process_error)?; + if !output.status.success() { + return Err(GitveilError::dependency(format!( + "age-keygen version check failed {}", + exit_status_description(output.status) + ))); + } + let stdout = std::str::from_utf8(output.stdout.as_slice()) + .map_err(|_| GitveilError::dependency("age-keygen version output is not UTF-8"))?; + let token = stdout + .split_whitespace() + .find(|token| { + token + .trim_start_matches('v') + .chars() + .next() + .is_some_and(|value| value.is_ascii_digit()) + }) + .ok_or_else(|| GitveilError::dependency("age-keygen version output is unrecognized"))?; + let version = Version::parse(token.trim_start_matches('v')) + .map_err(|_| GitveilError::dependency("age-keygen version output is unrecognized"))?; + if version + != Version::new( + AGE_KEYGEN_VERSION.0, + AGE_KEYGEN_VERSION.1, + AGE_KEYGEN_VERSION.2, + ) + { + return Err(GitveilError::dependency(format!( + "unsupported age-keygen version {version}; required {}.{}.{}", + AGE_KEYGEN_VERSION.0, AGE_KEYGEN_VERSION.1, AGE_KEYGEN_VERSION.2 + ))); + } + Ok(()) + } +} + +fn process_exit_error(operation: &str, status: ExitStatus) -> GitveilError { + GitveilError::new( + ErrorCategory::Process, + format!("{operation} {}", exit_status_description(status)), + ) +} + +fn exit_status_description(status: ExitStatus) -> String { + status.code().map_or_else( + || "after termination by signal".to_owned(), + |code| format!("with exit code {code}"), + ) +} + +fn classify_dependency_process_error(_error: GitveilError) -> GitveilError { + GitveilError::dependency("age-keygen dependency version check could not be completed") +} diff --git a/src/age/classify.rs b/src/age/classify.rs new file mode 100644 index 0000000..724bbfb --- /dev/null +++ b/src/age/classify.rs @@ -0,0 +1,164 @@ +use crate::error::{ErrorCategory, GitveilError, Result}; +use crate::recipient::AgeRecipient; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum GenerationFailure { + NoSpace, + PermissionDenied, + ReadOnlyFilesystem, + OutputIo, + Execution, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum RecipientFailure { + IdentityUnavailable, + InvalidIdentity, + Protocol, + Execution, +} + +pub(super) fn classify_generation(stderr: &[u8]) -> GenerationFailure { + let message = String::from_utf8_lossy(stderr).to_ascii_lowercase(); + if message.contains("no space left on device") { + GenerationFailure::NoSpace + } else if message.contains("permission denied") { + GenerationFailure::PermissionDenied + } else if message.contains("read-only file system") { + GenerationFailure::ReadOnlyFilesystem + } else if message.contains("failed to open output file") + || message.contains("failed to close output file") + { + GenerationFailure::OutputIo + } else { + GenerationFailure::Execution + } +} + +pub(super) fn classify_recipient(stderr: &[u8]) -> RecipientFailure { + let message = String::from_utf8_lossy(stderr).to_ascii_lowercase(); + if message.contains("failed to open input file") { + RecipientFailure::IdentityUnavailable + } else if message.contains("failed to parse input") + || message.contains("no identities found in the input") + { + RecipientFailure::InvalidIdentity + } else if message.contains("internal error: unexpected identity type") { + RecipientFailure::Protocol + } else { + RecipientFailure::Execution + } +} + +pub(super) fn parse_recipients(stdout: &[u8]) -> Result> { + let stdout = std::str::from_utf8(stdout).map_err(|_| { + GitveilError::new( + ErrorCategory::Protocol, + "age-keygen recipient output is not UTF-8", + ) + })?; + let recipients = stdout + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + AgeRecipient::new(line).map_err(|_| { + GitveilError::new( + ErrorCategory::Protocol, + "age-keygen returned an invalid public recipient", + ) + }) + }) + .collect::>>()?; + if recipients.is_empty() { + return Err(GitveilError::new( + ErrorCategory::Protocol, + "age-keygen returned no public recipients", + )); + } + Ok(recipients) +} + +#[cfg(test)] +mod tests { + use crate::error::ErrorCategory; + + use super::{ + GenerationFailure, RecipientFailure, classify_generation, classify_recipient, + parse_recipients, + }; + + #[test] + fn classifies_generation_output_failures_without_exposing_stderr() { + for (stderr, expected) in [ + ( + "failed to open output file: no space left on device", + GenerationFailure::NoSpace, + ), + ( + "failed to open output file: permission denied", + GenerationFailure::PermissionDenied, + ), + ( + "failed to open output file: read-only file system", + GenerationFailure::ReadOnlyFilesystem, + ), + ( + "failed to close output file: input/output error", + GenerationFailure::OutputIo, + ), + ("unexpected failure", GenerationFailure::Execution), + ] { + assert_eq!(classify_generation(stderr.as_bytes()), expected); + } + assert_eq!( + classify_generation(&[0xff, 0xfe]), + GenerationFailure::Execution + ); + } + + #[test] + fn classifies_recipient_failures_without_exposing_stderr() { + for (stderr, expected) in [ + ( + "failed to open input file: no such file or directory", + RecipientFailure::IdentityUnavailable, + ), + ( + "failed to parse input: malformed identity", + RecipientFailure::InvalidIdentity, + ), + ( + "no identities found in the input", + RecipientFailure::InvalidIdentity, + ), + ( + "internal error: unexpected identity type", + RecipientFailure::Protocol, + ), + ("unexpected failure", RecipientFailure::Execution), + ] { + assert_eq!(classify_recipient(stderr.as_bytes()), expected); + } + assert_eq!( + classify_recipient(&[0xff, 0xfe]), + RecipientFailure::Execution + ); + } + + #[test] + fn parses_only_nonempty_native_age_recipients() { + let recipient = "age1qyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqszqgpqyqs3290gq"; + let parsed = parse_recipients(format!("{recipient}\n\n").as_bytes()) + .expect("valid recipient output"); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].as_str(), recipient); + } + + #[test] + fn rejects_non_utf8_empty_and_invalid_recipient_output() { + for output in [&[0xff][..], b"", b"not-an-age-recipient\n"] { + let error = parse_recipients(output).expect_err("invalid recipient output"); + assert_eq!(error.category(), ErrorCategory::Protocol); + } + } +} diff --git a/src/cli.rs b/src/cli.rs index e2fcc49..7748c5d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,6 +4,7 @@ use clap::{Parser, Subcommand}; use crate::configure::{AddOutcomeKind, add, initialize}; use crate::error::{ErrorCategory, GitveilError, Result}; +use crate::identity; use crate::open::{OpenOutcome, open}; use crate::resolve::{ResolveOutcome, resolve}; use crate::runtime::{read_editor_token, run_internal_editor}; @@ -14,6 +15,7 @@ use crate::workspace::Workspace; const AFTER_HELP: &str = "\ Examples: + gitveil identity generate gitveil init --recipient age1... gitveil add .env --format dotenv gitveil open @@ -39,6 +41,11 @@ pub struct Cli { #[derive(Debug, Subcommand)] enum Command { + /// Generate local age identities and derive their public recipients + Identity { + #[command(subcommand)] + command: IdentityCommand, + }, /// Initialize Gitveil configuration at the Git repository root Init { /// Name of the initial recipient policy @@ -110,6 +117,22 @@ enum Command { }, } +#[derive(Debug, Subcommand)] +enum IdentityCommand { + /// Generate one native age identity without replacing an existing file + Generate { + /// Identity file to create (defaults to `SOPS_AGE_KEY_FILE` or the platform default key file) + #[arg(long, value_name = "PATH")] + output: Option, + }, + /// Print public recipients derived from one selected native age identity file + Recipients { + /// Identity file to read (defaults to `SOPS_AGE_KEY_FILE` or the platform default key file) + #[arg(long, value_name = "PATH")] + identity: Option, + }, +} + /// Severity tone for one report line; rendered as color on a TTY. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Tone { @@ -191,6 +214,7 @@ pub fn run(cli: Cli) -> Result { let gitveil_binary = std::env::current_exe() .map_err(|error| GitveilError::io("resolve current executable", None, &error))?; match cli.command { + Command::Identity { command } => run_identity(¤t, &gitveil_binary, command), Command::Init { policy, recipient } => run_initialize(¤t, &policy, &recipient), Command::Add { format, @@ -228,6 +252,37 @@ pub fn run(cli: Cli) -> Result { } } +fn identity_durability_warning(path: &Path, kind: std::io::ErrorKind) -> String { + format!( + "gitveil: warning: identity was published at {}, but directory durability could not be confirmed ({kind:?}); preserve the file and run gitveil identity recipients --identity {} if recipient output is lost", + path.display(), + path.display() + ) +} + +fn run_identity( + current: &Path, + gitveil_binary: &Path, + command: IdentityCommand, +) -> Result { + match command { + IdentityCommand::Generate { output } => { + let generated = identity::generate(current, gitveil_binary, output.as_deref())?; + println!("created age identity at {}", generated.path().display()); + println!("recipient: {}", generated.recipient()); + if let identity::PublishDurability::Unconfirmed(kind) = generated.durability() { + eprintln!("{}", identity_durability_warning(generated.path(), kind)); + } + } + IdentityCommand::Recipients { identity: path } => { + for recipient in identity::recipients(current, gitveil_binary, path.as_deref())? { + println!("{recipient}"); + } + } + } + Ok(RunOutcome::Success) +} + fn run_initialize(current: &Path, policy: &str, recipients: &[String]) -> Result { let outcome = initialize(current, policy, recipients)?; println!( @@ -463,7 +518,9 @@ pub fn parse() -> Cli { #[cfg(test)] mod tests { - use super::{Tone, error_exit_code, paint, shell_quote, status_tone}; + use super::{ + Tone, error_exit_code, identity_durability_warning, paint, shell_quote, status_tone, + }; use crate::baseline::BaselineDiff; use crate::error::ErrorCategory; use crate::status::{PairDataStatus, PairStatus, RecipientStatus}; @@ -536,6 +593,19 @@ mod tests { assert_eq!(shell_quote("secret'file.env"), "'secret'\"'\"'file.env'"); } + #[test] + fn post_publish_sync_warning_names_the_committed_identity_and_recovery_command() { + let warning = identity_durability_warning( + std::path::Path::new("/home/dev/.config/sops/age/keys.txt"), + std::io::ErrorKind::Other, + ); + assert!(warning.contains("identity was published at /home/dev/.config/sops/age/keys.txt")); + assert!(warning.contains("directory durability could not be confirmed")); + assert!(warning.contains( + "gitveil identity recipients --identity /home/dev/.config/sops/age/keys.txt" + )); + } + #[test] fn content_verification_failures_use_exit_three() { assert_eq!(error_exit_code(ErrorCategory::Integrity), 3); diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..fb37640 --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,456 @@ +use std::fs::{self, File}; +use std::io::{self, Seek, SeekFrom, Write}; +use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt}; +use std::path::{Path, PathBuf}; + +use rustix::fs::{Mode, OFlags, RenameFlags, open, renameat_with}; + +use crate::age::AgeKeygenBinary; +use crate::error::{ErrorCategory, GitveilError, Result}; +use crate::recipient::AgeRecipient; + +pub(crate) struct GeneratedIdentity { + path: PathBuf, + recipient: AgeRecipient, + durability: PublishDurability, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PublishDurability { + Confirmed, + Unconfirmed(io::ErrorKind), +} + +impl GeneratedIdentity { + pub(crate) fn path(&self) -> &Path { + &self.path + } + + pub(crate) fn recipient(&self) -> &AgeRecipient { + &self.recipient + } + + pub(crate) const fn durability(&self) -> PublishDurability { + self.durability + } +} + +pub(crate) fn generate( + current: &Path, + gitveil_binary: &Path, + requested: Option<&Path>, +) -> Result { + let destination = resolve_generation_destination(current, requested)?; + match fs::symlink_metadata(&destination) { + Ok(_) => { + return Err(GitveilError::configuration(format!( + "identity path already exists: {}", + destination.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(GitveilError::io( + "inspect identity destination", + Some(destination.clone()), + &error, + )); + } + } + let age_keygen = AgeKeygenBinary::discover(gitveil_binary)?; + let parent = destination + .parent() + .ok_or_else(|| GitveilError::configuration("identity path has no parent directory"))?; + ensure_parent(parent)?; + + let staging = tempfile::Builder::new() + .prefix(".gitveil-identity-") + .tempdir_in(parent) + .map_err(|error| { + GitveilError::io( + "create private identity staging directory", + Some(parent.to_path_buf()), + &error, + ) + })?; + fs::set_permissions(staging.path(), fs::Permissions::from_mode(0o700)).map_err(|error| { + GitveilError::io( + "secure identity staging directory", + Some(staging.path().to_path_buf()), + &error, + ) + })?; + let staged_path = staging.path().join("identity"); + let staged_secret = StagedSecret::new(staged_path.clone()); + age_keygen.generate(&staged_path)?; + secure_generated_identity(&staged_path)?; + let mut recipients = age_keygen.recipients(&staged_path)?; + if recipients.len() != 1 { + return Err(GitveilError::new( + ErrorCategory::Protocol, + "age-keygen generated an unexpected number of recipients", + )); + } + let recipient = recipients.remove(0); + let durability = publish_no_replace(&staged_path, &destination)?; + drop(staged_secret); + Ok(GeneratedIdentity { + path: destination, + recipient, + durability, + }) +} + +pub(crate) fn recipients( + current: &Path, + gitveil_binary: &Path, + requested: Option<&Path>, +) -> Result> { + let identity = resolve_recipient_input(current, requested)?; + AgeKeygenBinary::discover(gitveil_binary)?.recipients(&identity) +} + +fn resolve_generation_destination(current: &Path, requested: Option<&Path>) -> Result { + resolve_selected_identity_file(current, requested) +} + +fn resolve_recipient_input(current: &Path, requested: Option<&Path>) -> Result { + resolve_selected_identity_file(current, requested) +} + +fn resolve_selected_identity_file(current: &Path, requested: Option<&Path>) -> Result { + let selected = if let Some(path) = requested { + path.to_path_buf() + } else if let Some(path) = nonempty_environment_path("SOPS_AGE_KEY_FILE")? { + path + } else if let Some(config) = nonempty_environment_path("XDG_CONFIG_HOME")? { + config.join("sops/age/keys.txt") + } else { + let home = nonempty_environment_path("HOME")?.ok_or_else(|| { + GitveilError::configuration("HOME is unavailable; pass an explicit identity path") + })?; + #[cfg(target_os = "macos")] + let path = home.join("Library/Application Support/sops/age/keys.txt"); + #[cfg(target_os = "linux")] + let path = home.join(".config/sops/age/keys.txt"); + path + }; + if selected.as_os_str().is_empty() { + return Err(GitveilError::configuration("identity path cannot be empty")); + } + Ok(if selected.is_absolute() { + selected + } else { + current.join(selected) + }) +} + +fn nonempty_environment_path(name: &str) -> Result> { + let Some(value) = std::env::var_os(name) else { + return Ok(None); + }; + if value.is_empty() { + return Err(GitveilError::configuration(format!( + "{name} cannot be empty" + ))); + } + Ok(Some(PathBuf::from(value))) +} + +fn ensure_parent(parent: &Path) -> Result<()> { + let mut missing = Vec::new(); + let mut cursor = parent; + loop { + match fs::symlink_metadata(cursor) { + Ok(metadata) if metadata.is_dir() => break, + Ok(_) => { + return Err(GitveilError::configuration(format!( + "identity directory path is not a directory: {}", + cursor.display() + ))); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + missing.push(cursor.to_path_buf()); + cursor = cursor.parent().ok_or_else(|| { + GitveilError::configuration("identity directory has no existing ancestor") + })?; + } + Err(error) => { + return Err(GitveilError::io( + "inspect identity directory", + Some(cursor.to_path_buf()), + &error, + )); + } + } + } + + for directory in missing.into_iter().rev() { + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700); + match builder.create(&directory) { + Ok(()) => { + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).map_err( + |error| { + GitveilError::io( + "secure created identity directory", + Some(directory.clone()), + &error, + ) + }, + )?; + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let metadata = fs::symlink_metadata(&directory).map_err(|inspect_error| { + GitveilError::io( + "inspect concurrently created identity directory", + Some(directory.clone()), + &inspect_error, + ) + })?; + if !metadata.is_dir() { + return Err(GitveilError::configuration(format!( + "identity directory path is not a directory: {}", + directory.display() + ))); + } + } + Err(error) => { + return Err(GitveilError::io( + "create private identity directory", + Some(directory), + &error, + )); + } + } + } + Ok(()) +} + +fn secure_generated_identity(path: &Path) -> Result<()> { + let path_metadata = fs::symlink_metadata(path).map_err(|error| { + GitveilError::io( + "inspect generated identity path", + Some(path.to_path_buf()), + &error, + ) + })?; + if !path_metadata.is_file() + || path_metadata.file_type().is_symlink() + || path_metadata.nlink() != 1 + { + return Err(GitveilError::new( + ErrorCategory::Protocol, + "age-keygen did not create an independent regular identity file", + )); + } + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).map_err(|error| { + GitveilError::io( + "set generated identity permissions", + Some(path.to_path_buf()), + &error, + ) + })?; + + let file = open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map(File::from) + .map_err(|error| { + GitveilError::new( + ErrorCategory::Protocol, + format!("age-keygen did not create a safe identity file: {error}"), + ) + })?; + let metadata = file.metadata().map_err(|error| { + GitveilError::io( + "inspect generated identity", + Some(path.to_path_buf()), + &error, + ) + })?; + if !metadata.is_file() || metadata.nlink() != 1 { + return Err(GitveilError::new( + ErrorCategory::Protocol, + "age-keygen did not create an independent regular identity file", + )); + } + file.set_permissions(fs::Permissions::from_mode(0o600)) + .and_then(|()| file.sync_all()) + .map_err(|error| { + GitveilError::io( + "secure generated identity", + Some(path.to_path_buf()), + &error, + ) + }) +} + +fn publish_no_replace(staged: &Path, destination: &Path) -> Result { + publish_no_replace_with_sync(staged, destination, File::sync_all) +} + +fn publish_no_replace_with_sync( + staged: &Path, + destination: &Path, + sync_destination_directory: impl FnOnce(&File) -> io::Result<()>, +) -> Result { + let staged_parent = staged.parent().ok_or_else(|| { + GitveilError::configuration("staged identity path has no parent directory") + })?; + let staged_name = staged + .file_name() + .ok_or_else(|| GitveilError::configuration("staged identity path has no file name"))?; + let destination_parent = destination + .parent() + .ok_or_else(|| GitveilError::configuration("identity path has no destination directory"))?; + let destination_name = destination + .file_name() + .ok_or_else(|| GitveilError::configuration("identity path must name a destination file"))?; + let staging_directory = File::open(staged_parent).map_err(|error| { + GitveilError::io( + "open identity staging directory", + Some(staged_parent.to_path_buf()), + &error, + ) + })?; + let destination_directory = File::open(destination_parent).map_err(|error| { + GitveilError::io( + "open identity destination directory", + Some(destination_parent.to_path_buf()), + &error, + ) + })?; + renameat_with( + &staging_directory, + staged_name, + &destination_directory, + destination_name, + RenameFlags::NOREPLACE, + ) + .map_err(|error| { + if error == rustix::io::Errno::EXIST { + GitveilError::configuration(format!( + "identity path already exists: {}", + destination.display() + )) + } else { + GitveilError::new( + ErrorCategory::Io, + format!( + "publish generated identity at {} failed: {error}", + destination.display() + ), + ) + } + })?; + Ok(match sync_destination_directory(&destination_directory) { + Ok(()) => PublishDurability::Confirmed, + Err(error) => PublishDurability::Unconfirmed(error.kind()), + }) +} + +struct StagedSecret { + path: PathBuf, +} + +impl StagedSecret { + const fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl Drop for StagedSecret { + fn drop(&mut self) { + let Ok(path_metadata) = fs::symlink_metadata(&self.path) else { + return; + }; + if !path_metadata.is_file() + || path_metadata.file_type().is_symlink() + || path_metadata.nlink() != 1 + { + return; + } + if fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600)).is_err() { + return; + } + let Ok(mut file) = open( + &self.path, + OFlags::RDWR | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map(File::from) else { + return; + }; + let Ok(metadata) = file.metadata() else { + return; + }; + if !metadata.is_file() || metadata.nlink() != 1 { + return; + } + let length = metadata.len(); + if file.seek(SeekFrom::Start(0)).is_err() { + return; + } + let zeros = [0_u8; 8192]; + let mut written = 0_u64; + while written < length { + let Ok(count) = usize::try_from((length - written).min(zeros.len() as u64)) else { + break; + }; + if file.write_all(&zeros[..count]).is_err() { + break; + } + written += count as u64; + } + let _ = file.sync_all(); + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::io; + use std::path::Path; + + use super::{PublishDurability, publish_no_replace_with_sync, resolve_selected_identity_file}; + + #[test] + fn explicit_relative_identity_paths_are_resolved_from_the_current_directory() { + assert_eq!( + resolve_selected_identity_file( + Path::new("/work"), + Some(Path::new("credentials/identity.txt")) + ) + .expect("identity path"), + Path::new("/work/credentials/identity.txt") + ); + } + + #[test] + fn a_directory_sync_failure_is_a_committed_publication_outcome() { + let root = tempfile::tempdir().expect("publication fixture"); + let staging = root.path().join("staging"); + fs::create_dir(&staging).expect("staging directory"); + let staged = staging.join("generated-identity"); + fs::write(&staged, b"private identity canary").expect("staged identity"); + let destination = root.path().join("published-identity"); + + let durability = publish_no_replace_with_sync(&staged, &destination, |_| { + Err(io::Error::other("injected directory sync failure")) + }) + .expect("rename commits before directory sync"); + + assert_eq!( + durability, + PublishDurability::Unconfirmed(io::ErrorKind::Other) + ); + assert_eq!( + fs::read(&destination).expect("published identity"), + b"private identity canary" + ); + assert!(!staged.exists()); + } +} diff --git a/src/lib.rs b/src/lib.rs index bc5fe73..1c1f0a8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #[cfg(not(any(target_os = "macos", target_os = "linux")))] compile_error!("Gitveil v1 supports macOS and Linux only"); +pub(crate) mod age; pub mod baseline; pub mod cli; pub mod config; @@ -9,6 +10,7 @@ pub(crate) mod confine; pub mod envelope; pub mod error; pub(crate) mod git; +pub(crate) mod identity; pub mod manifest; pub(crate) mod open; pub mod path; diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index d44154f..9c69eb1 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -1,8 +1,12 @@ mod ipc; mod lock; +mod process; +mod sidecar; mod temp; pub(crate) use ipc::read_editor_token; pub use ipc::{EditorEndpoint, receive_editor_payload, reject_editor_reuse, run_internal_editor}; pub use lock::{OperationLock, acquire_lock}; +pub(crate) use process::{ProcessOutput, run_capture}; +pub(crate) use sidecar::packaged_sidecar_path; pub use temp::{ClosedRuntimeFile, PrivateRuntime, RuntimeFile}; diff --git a/src/runtime/process.rs b/src/runtime/process.rs new file mode 100644 index 0000000..729f7bf --- /dev/null +++ b/src/runtime/process.rs @@ -0,0 +1,156 @@ +use std::io::{Read, Write}; +use std::process::{Command, ExitStatus, Stdio}; +use std::thread; +use std::time::Duration; + +use wait_timeout::ChildExt; + +use crate::error::{ErrorCategory, GitveilError, Result, SecretBytes}; + +pub(crate) struct ProcessOutput { + pub(crate) status: ExitStatus, + pub(crate) stdout: SecretBytes, + pub(crate) stderr: SecretBytes, +} + +pub(crate) fn run_capture( + mut command: Command, + input: Option<&SecretBytes>, + timeout: Duration, + program: &'static str, +) -> Result { + command + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().map_err(|error| { + GitveilError::new( + ErrorCategory::Process, + format!("could not start {program} process: {}", error.kind()), + ) + })?; + let stdin_writer = if let Some(input) = input { + let mut stdin = child.stdin.take().ok_or_else(|| { + GitveilError::new( + ErrorCategory::Process, + format!("{program} stdin unavailable"), + ) + })?; + let input = SecretBytes::new(input.as_slice().to_vec()); + Some(thread::spawn(move || stdin.write_all(input.as_slice()))) + } else { + None + }; + let stdout = child.stdout.take().ok_or_else(|| { + GitveilError::new( + ErrorCategory::Process, + format!("{program} stdout unavailable"), + ) + })?; + let stderr = child.stderr.take().ok_or_else(|| { + GitveilError::new( + ErrorCategory::Process, + format!("{program} stderr unavailable"), + ) + })?; + let stdout_reader = thread::spawn(move || read_all(stdout)); + let stderr_reader = thread::spawn(move || read_all(stderr)); + let (status, timed_out) = wait_for_child(&mut child, timeout, program)?; + let stdin_result = join_stdin_writer(stdin_writer, program); + let stdout = join_secret_reader(stdout_reader, "stdout", program)?; + let stderr = join_secret_reader(stderr_reader, "stderr", program)?; + if timed_out { + return Err(GitveilError::new( + ErrorCategory::Process, + format!("{program} process timed out"), + )); + } + if status.success() { + stdin_result?; + } + Ok(ProcessOutput { + status, + stdout, + stderr, + }) +} + +fn wait_for_child( + child: &mut std::process::Child, + timeout: Duration, + program: &'static str, +) -> Result<(ExitStatus, bool)> { + let status = child.wait_timeout(timeout).map_err(|error| { + GitveilError::new( + ErrorCategory::Process, + format!("could not wait for {program} process: {}", error.kind()), + ) + })?; + if let Some(status) = status { + return Ok((status, false)); + } + let _ = child.kill(); + let status = child.wait().map_err(|error| { + GitveilError::new( + ErrorCategory::Process, + format!("could not reap {program} process: {}", error.kind()), + ) + })?; + Ok((status, true)) +} + +fn join_stdin_writer( + writer: Option>>, + program: &'static str, +) -> Result> { + writer + .map(|writer| { + writer + .join() + .map_err(|_| { + GitveilError::new( + ErrorCategory::Process, + format!("{program} stdin writer failed"), + ) + })? + .map_err(|error| { + GitveilError::new( + ErrorCategory::Process, + format!("could not write {program} input: {}", error.kind()), + ) + }) + }) + .transpose() +} + +fn join_secret_reader( + reader: thread::JoinHandle>>, + stream: &str, + program: &'static str, +) -> Result { + reader + .join() + .map_err(|_| { + GitveilError::new( + ErrorCategory::Process, + format!("{program} {stream} reader failed"), + ) + })? + .map(SecretBytes::new) + .map_err(|error| { + GitveilError::new( + ErrorCategory::Process, + format!("could not read {program} {stream}: {}", error.kind()), + ) + }) +} + +fn read_all(mut reader: impl Read) -> std::io::Result> { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + Ok(bytes) +} diff --git a/src/runtime/sidecar.rs b/src/runtime/sidecar.rs new file mode 100644 index 0000000..3eaba7d --- /dev/null +++ b/src/runtime/sidecar.rs @@ -0,0 +1,33 @@ +use std::path::{Path, PathBuf}; + +use crate::error::{GitveilError, Result}; + +pub(crate) fn packaged_sidecar_path(gitveil_binary: &Path, name: &str) -> Result { + let bin = gitveil_binary.parent().ok_or_else(|| { + GitveilError::dependency("Gitveil executable has no installation directory") + })?; + let prefix = bin + .parent() + .ok_or_else(|| GitveilError::dependency("Gitveil executable has no installation prefix"))?; + Ok(prefix.join("libexec").join("gitveil").join(name)) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::packaged_sidecar_path; + + #[test] + fn sidecars_are_resolved_from_the_install_prefix() { + let binary = Path::new("/opt/gitveil/bin/gitveil"); + assert_eq!( + packaged_sidecar_path(binary, "sops").expect("SOPS path"), + Path::new("/opt/gitveil/libexec/gitveil/sops") + ); + assert_eq!( + packaged_sidecar_path(binary, "age-keygen").expect("age-keygen path"), + Path::new("/opt/gitveil/libexec/gitveil/age-keygen") + ); + } +} diff --git a/src/sops/client.rs b/src/sops/client.rs index aa471ae..2240ea1 100644 --- a/src/sops/client.rs +++ b/src/sops/client.rs @@ -9,7 +9,7 @@ use std::time::{Duration, Instant}; use zeroize::Zeroize; use super::classify::{SopsFailure, classify}; -use super::process::{ProcessOutput, SopsBinary, run_capture}; +use super::process::SopsBinary; use crate::config::SourceFormat; use crate::envelope::DecryptedEnvelope; use crate::error::{ErrorCategory, GitveilError, Result, SecretBytes}; @@ -18,6 +18,7 @@ use crate::recipient::{AgeRecipient, AgeRecipientPolicy}; use crate::runtime::{ ClosedRuntimeFile, EditorEndpoint, PrivateRuntime, receive_editor_payload, reject_editor_reuse, }; +use crate::runtime::{ProcessOutput, run_capture}; pub const SOPS_VERSION: (u64, u64, u64) = (3, 13, 3); const PROCESS_TIMEOUT: Duration = Duration::from_mins(1); @@ -72,8 +73,8 @@ impl SopsClient { .arg("--filename-override") .arg(managed_path.as_str()) .arg(input.path()); - let output = - run_capture(command, None, PROCESS_TIMEOUT).map_err(|_| SopsFailure::Execution)?; + let output = run_capture(command, None, PROCESS_TIMEOUT, "SOPS") + .map_err(|_| SopsFailure::Execution)?; if output.status.success() { Ok(output.stdout) } else { @@ -104,7 +105,7 @@ impl SopsClient { .arg("--filename-override") .arg(managed_path.as_str()) .arg(scaffold_file.path()); - let output = run_capture(command, None, PROCESS_TIMEOUT)?; + let output = run_capture(command, None, PROCESS_TIMEOUT, "SOPS")?; let baseline = require_success(&output, "encrypt")?; self.edit(&baseline, desired, managed_path) } @@ -155,7 +156,7 @@ impl SopsClient { .arg("--input-type") .arg("yaml") .arg(ciphertext.path()); - let output = run_capture(command, None, PROCESS_TIMEOUT)?; + let output = run_capture(command, None, PROCESS_TIMEOUT, "SOPS")?; require_success(&output, "update keys")?; ciphertext.read_public() } diff --git a/src/sops/process.rs b/src/sops/process.rs index 83aeda5..9d93a0d 100644 --- a/src/sops/process.rs +++ b/src/sops/process.rs @@ -1,13 +1,11 @@ -use std::io::{Read, Write}; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Stdio}; -use std::thread; +use std::process::Command; use std::time::Duration; use semver::Version; -use wait_timeout::ChildExt; -use crate::error::{ErrorCategory, GitveilError, Result, SecretBytes}; +use crate::error::{GitveilError, Result}; +use crate::runtime::{packaged_sidecar_path, run_capture}; use super::client::SOPS_VERSION; @@ -18,7 +16,7 @@ pub(crate) struct SopsBinary { impl SopsBinary { pub(crate) fn discover(explicit: Option, gitveil_binary: &Path) -> Result { - let packaged = packaged_sops_path(gitveil_binary)?; + let packaged = packaged_sidecar_path(gitveil_binary, "sops")?; let path = explicit .or_else(|| std::env::var_os("SOPS_BIN").map(PathBuf::from)) .or_else(|| packaged.is_file().then_some(packaged.clone())) @@ -40,7 +38,7 @@ impl SopsBinary { fn verify_version(&self) -> Result<()> { let mut command = Command::new(&self.path); command.arg("--disable-version-check").arg("--version"); - let output = run_capture(command, None, Duration::from_secs(15))?; + let output = run_capture(command, None, Duration::from_secs(15), "SOPS")?; if !output.status.success() { return Err(GitveilError::dependency( "SOPS version check failed with a non-zero exit code", @@ -68,161 +66,3 @@ impl SopsBinary { Ok(()) } } - -pub(crate) struct ProcessOutput { - pub status: ExitStatus, - pub stdout: SecretBytes, - pub stderr: SecretBytes, -} - -pub(crate) fn run_capture( - mut command: Command, - input: Option<&SecretBytes>, - timeout: Duration, -) -> Result { - command - .stdin(if input.is_some() { - Stdio::piped() - } else { - Stdio::null() - }) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = command.spawn().map_err(|error| { - GitveilError::new( - ErrorCategory::Process, - format!("could not start SOPS process: {}", error.kind()), - ) - })?; - let stdin_writer = if let Some(input) = input { - let mut stdin = child - .stdin - .take() - .ok_or_else(|| GitveilError::new(ErrorCategory::Process, "SOPS stdin unavailable"))?; - let input = SecretBytes::new(input.as_slice().to_vec()); - Some(thread::spawn(move || stdin.write_all(input.as_slice()))) - } else { - None - }; - let stdout = child - .stdout - .take() - .ok_or_else(|| GitveilError::new(ErrorCategory::Process, "SOPS stdout unavailable"))?; - let stderr = child - .stderr - .take() - .ok_or_else(|| GitveilError::new(ErrorCategory::Process, "SOPS stderr unavailable"))?; - let stdout_reader = thread::spawn(move || read_all(stdout)); - let stderr_reader = thread::spawn(move || read_all(stderr)); - let (status, timed_out) = wait_for_child(&mut child, timeout)?; - let stdin_result = join_stdin_writer(stdin_writer); - let stdout = join_secret_reader(stdout_reader, "stdout")?; - let stderr = join_secret_reader(stderr_reader, "stderr")?; - if timed_out { - return Err(GitveilError::new( - ErrorCategory::Process, - "SOPS process timed out", - )); - } - if status.success() { - stdin_result?; - } - Ok(ProcessOutput { - status, - stdout, - stderr, - }) -} - -fn wait_for_child( - child: &mut std::process::Child, - timeout: Duration, -) -> Result<(ExitStatus, bool)> { - let status = child.wait_timeout(timeout).map_err(|error| { - GitveilError::new( - ErrorCategory::Process, - format!("could not wait for SOPS process: {}", error.kind()), - ) - })?; - if let Some(status) = status { - return Ok((status, false)); - } - let _ = child.kill(); - let status = child.wait().map_err(|error| { - GitveilError::new( - ErrorCategory::Process, - format!("could not reap SOPS process: {}", error.kind()), - ) - })?; - Ok((status, true)) -} - -fn join_stdin_writer( - writer: Option>>, -) -> Result> { - writer - .map(|writer| { - writer - .join() - .map_err(|_| GitveilError::new(ErrorCategory::Process, "SOPS stdin writer failed"))? - .map_err(|error| { - GitveilError::new( - ErrorCategory::Process, - format!("could not write SOPS input: {}", error.kind()), - ) - }) - }) - .transpose() -} - -fn join_secret_reader( - reader: thread::JoinHandle>>, - stream: &str, -) -> Result { - reader - .join() - .map_err(|_| { - GitveilError::new( - ErrorCategory::Process, - format!("SOPS {stream} reader failed"), - ) - })? - .map(SecretBytes::new) - .map_err(|error| { - GitveilError::new( - ErrorCategory::Process, - format!("could not read SOPS {stream}: {}", error.kind()), - ) - }) -} - -fn read_all(mut reader: impl Read) -> std::io::Result> { - let mut bytes = Vec::new(); - reader.read_to_end(&mut bytes)?; - Ok(bytes) -} - -fn packaged_sops_path(gitveil_binary: &Path) -> Result { - let bin = gitveil_binary.parent().ok_or_else(|| { - GitveilError::dependency("Gitveil executable has no installation directory") - })?; - let prefix = bin - .parent() - .ok_or_else(|| GitveilError::dependency("Gitveil executable has no installation prefix"))?; - Ok(prefix.join("libexec").join("gitveil").join("sops")) -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::packaged_sops_path; - - #[test] - fn sidecar_path_is_resolved_from_the_install_prefix() { - assert_eq!( - packaged_sops_path(Path::new("/opt/gitveil/bin/gitveil")).expect("path"), - Path::new("/opt/gitveil/libexec/gitveil/sops") - ); - } -} diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs index c113cb1..1149ab1 100644 --- a/tests/cli_contract.rs +++ b/tests/cli_contract.rs @@ -3,18 +3,20 @@ pub mod support; use std::fs; use std::process::Command; -use support::{GitFixture, assert_success, command_output, sops_binary}; +use support::{GitFixture, age_keygen_binary, assert_success, command_output, sops_binary}; fn binary() -> std::path::PathBuf { assert_cmd::cargo::cargo_bin!("gitveil").to_path_buf() } #[test] -fn help_lists_exactly_the_seven_public_commands() { +fn help_lists_exactly_the_eight_public_commands() { let output = command_output(Command::new(binary()).arg("--help")); assert_eq!(output.status.code(), Some(0)); let help = String::from_utf8(output.stdout).expect("help UTF-8"); - for command in ["init", "add", "open", "seal", "status", "verify", "resolve"] { + for command in [ + "identity", "init", "add", "open", "seal", "status", "verify", "resolve", + ] { assert!(help.contains(command), "help must list {command}: {help}"); } for removed in [ @@ -38,6 +40,29 @@ fn help_lists_exactly_the_seven_public_commands() { } } +#[test] +fn identity_help_lists_generation_and_recipient_derivation() { + let output = command_output(Command::new(binary()).args(["identity", "--help"])); + assert_eq!(output.status.code(), Some(0)); + let help = String::from_utf8(output.stdout).expect("identity help UTF-8"); + assert!( + help.lines() + .any(|line| line.trim_start().starts_with("generate")) + ); + assert!( + help.lines() + .any(|line| line.trim_start().starts_with("recipients")) + ); + + let output = command_output(Command::new(binary()).args(["identity", "recipients", "--help"])); + assert_eq!(output.status.code(), Some(0)); + let help = String::from_utf8(output.stdout).expect("recipient help UTF-8"); + let normalized = help.split_whitespace().collect::>().join(" "); + assert!(normalized.contains("SOPS_AGE_KEY_FILE")); + assert!(normalized.contains("platform default key file")); + assert!(!normalized.contains("SOPS_AGE_KEY_CMD")); +} + #[test] fn profile_is_exposed_only_on_the_scoped_pair_commands() { for command in ["open", "seal", "status"] { @@ -71,6 +96,8 @@ fn usage_errors_exit_two() { assert_eq!(unknown.status.code(), Some(2)); let missing = command_output(&mut Command::new(binary())); assert_eq!(missing.status.code(), Some(2)); + let identity_without_operation = command_output(Command::new(binary()).arg("identity")); + assert_eq!(identity_without_operation.status.code(), Some(2)); let init_without_recipient = command_output(Command::new(binary()).arg("init")); assert_eq!(init_without_recipient.status.code(), Some(2)); let add_without_format = command_output(Command::new(binary()).args(["add", ".env"])); @@ -195,13 +222,34 @@ fn installed_layout_uses_the_private_sidecar_without_system_sops_or_age() { let prefix = tempfile::tempdir().expect("install prefix"); let installed_binary = prefix.path().join("bin/gitveil"); let installed_sops = prefix.path().join("libexec/gitveil/sops"); + let installed_age_keygen = prefix.path().join("libexec/gitveil/age-keygen"); fs::create_dir_all(installed_binary.parent().expect("bin parent")).expect("create bin"); fs::create_dir_all(installed_sops.parent().expect("sidecar parent")).expect("create libexec"); fs::copy(binary(), &installed_binary).expect("install gitveil"); fs::copy(sops_binary(), &installed_sops).expect("install sops"); + fs::copy(age_keygen_binary(), &installed_age_keygen).expect("install age-keygen"); fs::set_permissions(&installed_binary, fs::Permissions::from_mode(0o755)) .expect("gitveil mode"); fs::set_permissions(&installed_sops, fs::Permissions::from_mode(0o755)).expect("sops mode"); + fs::set_permissions(&installed_age_keygen, fs::Permissions::from_mode(0o755)) + .expect("age-keygen mode"); + + let mut identity_command = fixture.command_with_identity(&installed_binary); + identity_command + .env_remove("AGE_KEYGEN_BIN") + .env("PATH", "/usr/bin:/bin") + .args(["identity", "recipients", "--identity"]) + .arg(fixture.identity()); + let identity_output = assert_success( + command_output(&mut identity_command), + "installed recipient derivation", + ); + assert_eq!( + String::from_utf8(identity_output.stdout) + .expect("installed recipient UTF-8") + .trim(), + fixture.recipient() + ); let mut command = fixture.command_with_identity(&installed_binary); command diff --git a/tests/integration_identity.rs b/tests/integration_identity.rs new file mode 100644 index 0000000..f5f5a1c --- /dev/null +++ b/tests/integration_identity.rs @@ -0,0 +1,599 @@ +pub mod support; + +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use support::{ + age_keygen_binary, assert_no_private_identity_output, assert_success, child_output, + command_output, generate_age_identity, +}; + +fn binary() -> PathBuf { + assert_cmd::cargo::cargo_bin!("gitveil").to_path_buf() +} + +struct IdentityFixture { + root: tempfile::TempDir, + home: PathBuf, + xdg: PathBuf, +} + +impl IdentityFixture { + fn new() -> Self { + let root = tempfile::tempdir().expect("identity fixture"); + let home = root.path().join("home"); + let xdg = home.join("xdg"); + fs::create_dir_all(&xdg).expect("create isolated config root"); + Self { root, home, xdg } + } + + fn command(&self) -> Command { + let mut command = Command::new(binary()); + self.isolate(&mut command); + command + } + + fn command_with_restrictive_umask(&self) -> Command { + let mut command = Command::new("sh"); + command + .args([ + "-c", + "umask 0777; program=$1; shift; exec \"$program\" \"$@\"", + "gitveil-identity-test", + ]) + .arg(binary()); + self.isolate(&mut command); + command + } + + fn isolate(&self, command: &mut Command) { + command + .current_dir(self.root.path()) + .env("HOME", &self.home) + .env("XDG_CONFIG_HOME", &self.xdg) + .env("AGE_KEYGEN_BIN", age_keygen_binary()) + .env_remove("SOPS_AGE_KEY") + .env_remove("SOPS_AGE_KEY_FILE") + .env_remove("SOPS_AGE_KEY_CMD"); + } + + fn default_identity(&self) -> PathBuf { + self.xdg.join("sops/age/keys.txt") + } +} + +fn recipient_from_age_keygen(identity: &Path) -> String { + let output = assert_success( + command_output(Command::new(age_keygen_binary()).arg("-y").arg(identity)), + "derive recipient independently", + ); + String::from_utf8(output.stdout) + .expect("recipient UTF-8") + .trim() + .to_owned() +} + +#[test] +fn generate_creates_the_default_identity_without_revealing_private_material() { + let fixture = IdentityFixture::new(); + let output = assert_success( + command_output( + fixture + .command_with_restrictive_umask() + .args(["identity", "generate"]), + ), + "generate identity", + ); + + let identity_path = fixture.default_identity(); + let identity = fs::read(&identity_path).expect("generated identity"); + assert!(identity.starts_with(b"# created:")); + assert!( + identity + .windows(b"AGE-SECRET-KEY-".len()) + .any(|value| value == b"AGE-SECRET-KEY-") + ); + assert_eq!( + fs::metadata(&identity_path) + .expect("identity metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + for directory in [fixture.xdg.join("sops"), fixture.xdg.join("sops/age")] { + assert_eq!( + fs::metadata(&directory) + .expect("created identity directory metadata") + .permissions() + .mode() + & 0o777, + 0o700, + "{} must be owner-only", + directory.display() + ); + } + assert_no_private_identity_output(&output, &identity); + + let recipient = recipient_from_age_keygen(&identity_path); + let stdout = String::from_utf8(output.stdout).expect("generate output UTF-8"); + assert!(stdout.contains(&format!( + "created age identity at {}", + identity_path.display() + ))); + assert!(stdout.contains(&format!("recipient: {recipient}"))); +} + +#[test] +fn generation_uses_the_platform_sops_path_when_xdg_is_unset() { + let fixture = IdentityFixture::new(); + let mut command = fixture.command_with_restrictive_umask(); + command.env_remove("XDG_CONFIG_HOME"); + assert_success( + command_output(command.args(["identity", "generate"])), + "generate platform-default identity", + ); + + #[cfg(target_os = "macos")] + let created_directories = [ + fixture.home.join("Library"), + fixture.home.join("Library/Application Support"), + fixture.home.join("Library/Application Support/sops"), + fixture.home.join("Library/Application Support/sops/age"), + ]; + #[cfg(target_os = "linux")] + let created_directories = [ + fixture.home.join(".config"), + fixture.home.join(".config/sops"), + fixture.home.join(".config/sops/age"), + ]; + let identity = created_directories + .last() + .expect("identity directory") + .join("keys.txt"); + assert!(identity.is_file()); + assert!(recipient_from_age_keygen(&identity).starts_with("age1")); + for directory in created_directories { + assert_eq!( + fs::metadata(&directory) + .expect("platform identity directory metadata") + .permissions() + .mode() + & 0o777, + 0o700, + "{} must be owner-only", + directory.display() + ); + } +} + +#[test] +fn recipients_uses_the_default_or_explicit_identity_and_prints_only_public_values() { + let fixture = IdentityFixture::new(); + let default_identity = fixture.default_identity(); + fs::create_dir_all(default_identity.parent().expect("default identity parent")) + .expect("create default identity parent"); + let default_recipient = generate_age_identity(&default_identity); + + let default_output = assert_success( + command_output(fixture.command().args(["identity", "recipients"])), + "derive default recipient", + ); + assert_eq!( + default_output.stdout, + format!("{default_recipient}\n").as_bytes() + ); + assert!(default_output.stderr.is_empty()); + + let explicit_identity = fixture.root.path().join("explicit-identity.txt"); + let explicit_recipient = generate_age_identity(&explicit_identity); + let explicit_output = assert_success( + command_output(fixture.command().args([ + "identity", + "recipients", + "--identity", + explicit_identity.to_str().expect("identity path UTF-8"), + ])), + "derive explicit recipient", + ); + assert_eq!( + explicit_output.stdout, + format!("{explicit_recipient}\n").as_bytes() + ); + let private = fs::read(explicit_identity).expect("explicit private identity"); + assert_no_private_identity_output(&explicit_output, &private); +} + +#[test] +fn recipients_reads_only_the_selected_identity_file() { + let fixture = IdentityFixture::new(); + let marker = fixture.root.path().join("identity-command-ran"); + let command_source = fixture.root.path().join("identity-command"); + fs::write( + &command_source, + format!("#!/bin/sh\ntouch '{}'\n", marker.display()), + ) + .expect("identity command"); + fs::set_permissions(&command_source, fs::Permissions::from_mode(0o700)) + .expect("identity command mode"); + + let mut command = fixture.command(); + command + .env("SOPS_AGE_KEY", "AGE-SECRET-KEY-INLINE-CANARY") + .env("SOPS_AGE_KEY_CMD", &command_source); + let output = command_output(command.args(["identity", "recipients"])); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("could not derive recipients from the selected identity file") + ); + assert!(!marker.exists()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("INLINE-CANARY")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("INLINE-CANARY")); +} + +#[test] +fn recipients_reports_missing_and_invalid_identity_files_without_leaking_input() { + let fixture = IdentityFixture::new(); + let missing = fixture.root.path().join("missing-identity.txt"); + let missing_output = command_output(fixture.command().args([ + "identity", + "recipients", + "--identity", + missing.to_str().expect("missing identity path UTF-8"), + ])); + assert_eq!(missing_output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&missing_output.stderr) + .contains("could not derive recipients from the selected identity file") + ); + + let invalid = fixture.root.path().join("invalid-identity.txt"); + fs::write(&invalid, b"AGE-SECRET-KEY-INVALID-INPUT-CANARY\n").expect("invalid identity"); + let invalid_output = command_output(fixture.command().args([ + "identity", + "recipients", + "--identity", + invalid.to_str().expect("invalid identity path UTF-8"), + ])); + assert_eq!(invalid_output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&invalid_output.stderr) + .contains("selected identity file is not a valid native age identity") + ); + assert!(!String::from_utf8_lossy(&invalid_output.stdout).contains("INPUT-CANARY")); + assert!(!String::from_utf8_lossy(&invalid_output.stderr).contains("INPUT-CANARY")); +} + +#[test] +fn recipient_protocol_failures_are_distinct_and_redacted() { + let fixture = IdentityFixture::new(); + let fake = fixture.root.path().join("recipient-output-age-keygen"); + fs::write( + &fake, + "#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then\n printf 'v1.3.1\\n'\n exit 0\nfi\ncase \"$RECIPIENT_OUTPUT\" in\n non-utf8) printf '\\377' ;;\n invalid) printf 'not-an-age-recipient\\n' ;;\n empty) : ;;\nesac\n", + ) + .expect("recipient-output age-keygen"); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)) + .expect("recipient-output age-keygen mode"); + let identity = fixture.root.path().join("unused-identity.txt"); + + for (mode, diagnostic) in [ + ("non-utf8", "recipient output is not UTF-8"), + ("invalid", "returned an invalid public recipient"), + ("empty", "returned no public recipients"), + ] { + let mut command = fixture.command(); + command + .env("AGE_KEYGEN_BIN", &fake) + .env("RECIPIENT_OUTPUT", mode); + let output = command_output(command.args([ + "identity", + "recipients", + "--identity", + identity.to_str().expect("identity path UTF-8"), + ])); + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains(diagnostic)); + assert!(!String::from_utf8_lossy(&output.stderr).contains("not-an-age-recipient")); + } +} + +#[test] +fn generate_honors_the_sops_identity_path_and_never_replaces_an_existing_leaf() { + let fixture = IdentityFixture::new(); + let identity_path = fixture.root.path().join("credentials/team.txt"); + let mut command = fixture.command(); + command.env("SOPS_AGE_KEY_FILE", &identity_path); + assert_success( + command_output(command.args(["identity", "generate"])), + "generate configured identity", + ); + assert!(identity_path.is_file()); + + let original = fs::read(&identity_path).expect("configured identity"); + let mut command = fixture.command(); + command.env("SOPS_AGE_KEY_FILE", &identity_path); + let output = command_output(command.args(["identity", "generate"])); + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("already exists")); + assert_eq!( + fs::read(&identity_path).expect("preserved identity"), + original + ); + + let symlink_path = fixture.root.path().join("identity-link.txt"); + symlink(&identity_path, &symlink_path).expect("identity symlink"); + let output = command_output(fixture.command().args([ + "identity", + "generate", + "--output", + symlink_path.to_str().expect("symlink path UTF-8"), + ])); + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + fs::read(&identity_path).expect("preserved symlink target"), + original + ); +} + +#[test] +fn concurrent_generation_publishes_exactly_one_complete_identity() { + let fixture = IdentityFixture::new(); + let identity_path = fixture.root.path().join("concurrent/identity.txt"); + let mut first = fixture.command(); + first + .args(["identity", "generate", "--output"]) + .arg(&identity_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut second = fixture.command(); + second + .args(["identity", "generate", "--output"]) + .arg(&identity_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let first = first.spawn().expect("start first identity generation"); + let second = second.spawn().expect("start second identity generation"); + let outputs = [child_output(first), child_output(second)]; + assert_eq!( + outputs + .iter() + .filter(|output| output.status.success()) + .count(), + 1 + ); + assert_eq!( + outputs + .iter() + .filter(|output| output.status.code() == Some(1)) + .count(), + 1 + ); + assert!(identity_path.is_file()); + let recipient = recipient_from_age_keygen(&identity_path); + assert!(recipient.starts_with("age1")); + assert_eq!( + fs::metadata(&identity_path) + .expect("concurrent identity metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); +} + +#[test] +fn generation_failure_leaves_no_identity_and_redacts_child_output() { + let fixture = IdentityFixture::new(); + let fake = fixture.root.path().join("failing-age-keygen"); + fs::write( + &fake, + "#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then\n printf 'v1.3.1\\n'\n exit 0\nfi\noutput=\nwhile [ \"$#\" -gt 0 ]; do\n if [ \"$1\" = \"-o\" ]; then\n shift\n output=$1\n fi\n shift\ndone\nprintf 'AGE-SECRET-KEY-FAILURE-CANARY\\n' >\"$output\"\nprintf 'AGE-SECRET-KEY-FAILURE-CANARY\\n' >&2\nexit 9\n", + ) + .expect("fake age-keygen"); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)).expect("fake executable mode"); + + let identity_path = fixture.root.path().join("failed.txt"); + let mut command = fixture.command_with_restrictive_umask(); + command.env("AGE_KEYGEN_BIN", &fake); + let output = command_output(command.args([ + "identity", + "generate", + "--output", + identity_path.to_str().expect("identity path UTF-8"), + ])); + assert_eq!(output.status.code(), Some(1)); + assert!(!identity_path.exists()); + assert!(!String::from_utf8_lossy(&output.stdout).contains("FAILURE-CANARY")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("FAILURE-CANARY")); + assert!( + fs::read_dir(fixture.root.path()) + .expect("scan identity fixture") + .all(|entry| !entry + .expect("identity fixture entry") + .file_name() + .to_string_lossy() + .starts_with(".gitveil-identity-")) + ); +} + +#[test] +fn generation_reports_safe_classified_output_failures() { + let fixture = IdentityFixture::new(); + let fake = fixture.root.path().join("no-space-age-keygen"); + fs::write( + &fake, + "#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then\n printf 'v1.3.1\\n'\n exit 0\nfi\nprintf '%s\\n' 'age-keygen: error: failed to close output file: no space left on device' >&2\nprintf '%s\\n' 'AGE-SECRET-KEY-CLASSIFIER-CANARY' >&2\nexit 9\n", + ) + .expect("no-space age-keygen"); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)) + .expect("no-space age-keygen mode"); + let identity = fixture.root.path().join("no-space.txt"); + let mut command = fixture.command(); + command.env("AGE_KEYGEN_BIN", &fake); + let output = command_output(command.args([ + "identity", + "generate", + "--output", + identity.to_str().expect("identity path UTF-8"), + ])); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("age-keygen could not write the identity: no space left on device") + ); + assert!(!String::from_utf8_lossy(&output.stderr).contains("CLASSIFIER-CANARY")); + assert!(!identity.exists()); +} + +#[test] +fn generated_staging_symlinks_are_rejected_without_touching_their_targets() { + let fixture = IdentityFixture::new(); + let protected = fixture.root.path().join("protected-private-material"); + fs::write(&protected, b"PROTECTED-PRIVATE-CANARY").expect("protected target"); + let fake = fixture.root.path().join("symlink-age-keygen"); + fs::write( + &fake, + "#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then\n printf 'v1.3.1\\n'\n exit 0\nfi\nshift\nln -s \"$PROTECTED_TARGET\" \"$1\"\n", + ) + .expect("symlink age-keygen"); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)).expect("symlink age-keygen mode"); + let identity = fixture.root.path().join("generated.txt"); + let mut command = fixture.command(); + command + .env("AGE_KEYGEN_BIN", &fake) + .env("PROTECTED_TARGET", &protected); + let output = command_output(command.args([ + "identity", + "generate", + "--output", + identity.to_str().expect("identity path UTF-8"), + ])); + + assert_eq!(output.status.code(), Some(1)); + assert!(!identity.exists()); + assert_eq!( + fs::read(&protected).expect("preserved protected target"), + b"PROTECTED-PRIVATE-CANARY" + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("PRIVATE-CANARY")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("PRIVATE-CANARY")); +} + +#[test] +fn generated_staging_hard_links_are_rejected_without_touching_their_targets() { + let fixture = IdentityFixture::new(); + let protected = fixture.root.path().join("hard-linked-private-material"); + fs::write(&protected, b"HARD-LINKED-PRIVATE-CANARY").expect("protected target"); + fs::set_permissions(&protected, fs::Permissions::from_mode(0o640)) + .expect("protected target mode"); + let fake = fixture.root.path().join("hard-link-age-keygen"); + fs::write( + &fake, + "#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then\n printf 'v1.3.1\\n'\n exit 0\nfi\nshift\nln \"$PROTECTED_TARGET\" \"$1\"\n", + ) + .expect("hard-link age-keygen"); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)) + .expect("hard-link age-keygen mode"); + let identity = fixture.root.path().join("hard-link-generated.txt"); + let mut command = fixture.command(); + command + .env("AGE_KEYGEN_BIN", &fake) + .env("PROTECTED_TARGET", &protected); + let output = command_output(command.args([ + "identity", + "generate", + "--output", + identity.to_str().expect("identity path UTF-8"), + ])); + + assert_eq!(output.status.code(), Some(1)); + assert!(!identity.exists()); + assert_eq!( + fs::read(&protected).expect("preserved hard-link target"), + b"HARD-LINKED-PRIVATE-CANARY" + ); + assert_eq!( + fs::metadata(&protected) + .expect("hard-link target metadata") + .permissions() + .mode() + & 0o777, + 0o640 + ); + assert!(!String::from_utf8_lossy(&output.stdout).contains("PRIVATE-CANARY")); + assert!(!String::from_utf8_lossy(&output.stderr).contains("PRIVATE-CANARY")); +} + +#[test] +fn identity_commands_never_fall_back_to_age_keygen_on_path() { + let fixture = IdentityFixture::new(); + let tools = fixture.root.path().join("path-tools"); + fs::create_dir(&tools).expect("create PATH tool directory"); + let marker = fixture.root.path().join("path-age-keygen-ran"); + let fake = tools.join("age-keygen"); + fs::write( + &fake, + format!( + "#!/bin/sh\ntouch '{}'\nprintf 'v1.3.1\\n'\n", + marker.display() + ), + ) + .expect("PATH age-keygen"); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)).expect("PATH age-keygen mode"); + + let mut command = fixture.command(); + command.env_remove("AGE_KEYGEN_BIN").env("PATH", &tools); + let output = command_output(command.args(["identity", "recipients"])); + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("packaged age-keygen sidecar")); + assert!(!marker.exists()); +} + +#[test] +fn identity_generation_reports_an_age_keygen_override_that_cannot_start() { + let fixture = IdentityFixture::new(); + let missing = fixture.root.path().join("missing-age-keygen"); + let unavailable_identity = fixture.root.path().join("unavailable/identity.txt"); + let mut command = fixture.command(); + command.env("AGE_KEYGEN_BIN", &missing); + let output = command_output(command.args([ + "identity", + "generate", + "--output", + unavailable_identity.to_str().expect("identity path UTF-8"), + ])); + + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("AGE_KEYGEN_BIN does not point to a file") + ); + assert!(!unavailable_identity.exists()); + assert!( + !unavailable_identity + .parent() + .expect("identity parent") + .exists() + ); +} + +#[test] +fn identity_commands_reject_a_wrong_version_sidecar() { + let fixture = IdentityFixture::new(); + let wrong = fixture.root.path().join("wrong-age-keygen"); + fs::write(&wrong, "#!/bin/sh\nprintf 'v1.2.0\\n'\n").expect("wrong age-keygen"); + fs::set_permissions(&wrong, fs::Permissions::from_mode(0o700)).expect("wrong executable mode"); + let mut command = fixture.command(); + command.env("AGE_KEYGEN_BIN", &wrong); + let output = command_output(command.args(["identity", "recipients"])); + assert_eq!(output.status.code(), Some(1)); + assert!( + String::from_utf8_lossy(&output.stderr).contains("unsupported age-keygen version 1.2.0") + ); +} diff --git a/tests/integration_quickstart.rs b/tests/integration_quickstart.rs index 821de3b..74c571c 100644 --- a/tests/integration_quickstart.rs +++ b/tests/integration_quickstart.rs @@ -1,7 +1,7 @@ pub mod support; use std::fs; -use std::os::unix::fs::{PermissionsExt, symlink}; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::time::Duration; @@ -28,8 +28,6 @@ impl QuickstartFixture { let workspaces = root.path().join("workspaces"); fs::create_dir(&tools).expect("create tool directory"); fs::create_dir(&workspaces).expect("create workspace parent"); - symlink(age_keygen_binary(), tools.join("age-keygen")) - .expect("link checksum-pinned age-keygen"); Self { root, tools, @@ -53,6 +51,7 @@ impl QuickstartFixture { .env("PATH", path) .env("GITVEIL_BIN", &self.gitveil) .env("SOPS_BIN", sops_binary()) + .env("AGE_KEYGEN_BIN", age_keygen_binary()) .env("TMPDIR", &self.workspaces) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -301,20 +300,37 @@ fn executable_example_preserves_diagnostics_redacts_secrets_and_cleans_up_on_fai } #[test] -fn executable_example_preserves_age_keygen_failure_diagnostics_and_cleans_up() { +fn executable_example_redacts_age_keygen_failure_and_cleans_up() { let fixture = QuickstartFixture::new(); - fixture.replace_tool( - "age-keygen", - b"#!/bin/sh\nprintf '%s\\n' 'age-keygen: permission denied writing identity' >&2\nexit 9\n", + let failing_age_keygen = fixture.root.path().join("failing-age-keygen"); + fs::write( + &failing_age_keygen, + b"#!/bin/sh\nif [ \"${1:-}\" = \"--version\" ]; then\n printf 'v1.3.1\\n'\n exit 0\nfi\nprintf '%s\\n' 'AGE-SECRET-KEY-FAILURE-CANARY' >&2\nexit 9\n", + ) + .expect("write failing age-keygen"); + fs::set_permissions(&failing_age_keygen, fs::Permissions::from_mode(0o755)) + .expect("make failing age-keygen executable"); + let mut command = fixture.command(); + command.env("AGE_KEYGEN_BIN", failing_age_keygen); + let output = child_output_with_timeout( + command.spawn().expect("start failing quickstart"), + QUICKSTART_TIMEOUT, ); - let output = fixture.run(&[]); assert!(!output.status.success()); assert!(contains( &output.stderr, - b"age-keygen: permission denied writing identity" + b"age-keygen identity generation failed with exit code 9" )); - assert_no_secret_output(&output, &[b"AGE-SECRET-KEY-", b"age1", PLAINTEXT_CANARY]); + assert_no_secret_output( + &output, + &[ + b"AGE-SECRET-KEY-", + b"FAILURE-CANARY", + b"age1", + PLAINTEXT_CANARY, + ], + ); fixture.assert_no_workspaces(); } @@ -351,13 +367,27 @@ fn executable_example_does_not_treat_a_git_ignore_failure_as_not_ignored() { } #[test] -fn executable_example_reports_missing_age_keygen_before_creating_a_workspace() { +fn executable_example_cleans_up_its_workspace_when_age_keygen_cannot_start() { let fixture = QuickstartFixture::new(); - fs::remove_file(fixture.tools.join("age-keygen")).expect("remove age-keygen test link"); - let output = fixture.run(&[]); + let mut command = fixture.command(); + command.env( + "AGE_KEYGEN_BIN", + fixture.root.path().join("missing-age-keygen"), + ); + let output = child_output_with_timeout( + command.spawn().expect("start dependency failure"), + QUICKSTART_TIMEOUT, + ); assert!(!output.status.success()); - assert!(contains(&output.stderr, b"age-keygen is required")); + assert!(contains( + &output.stderr, + b"AGE_KEYGEN_BIN does not point to a file" + )); + assert!(contains( + &output.stderr, + b"Gitveil quickstart failed: gitveil identity generation failed" + )); assert_no_secret_output(&output, &[b"AGE-SECRET-KEY-", b"age1", PLAINTEXT_CANARY]); fixture.assert_no_workspaces(); } diff --git a/tests/package_contract.rs b/tests/package_contract.rs index 6cc9dd1..b9b385a 100644 --- a/tests/package_contract.rs +++ b/tests/package_contract.rs @@ -5,7 +5,10 @@ use std::path::{Path, PathBuf}; use std::process::Command; use gitveil::sops::SOPS_VERSION; -use support::{assert_success, command_output, sops_binary}; +use support::{ + age_archive, age_keygen_binary, assert_no_private_identity_output, assert_success, + command_output, sops_binary, +}; fn installer_command() -> Command { let root = Path::new(env!("CARGO_MANIFEST_DIR")); @@ -17,7 +20,11 @@ fn installer_command() -> Command { .arg("--binary") .arg(assert_cmd::cargo::cargo_bin!("gitveil")) .arg("--sops-bin") - .arg(sops_binary()); + .arg(sops_binary()) + .arg("--age-keygen-bin") + .arg(age_keygen_binary()) + .arg("--age-keygen-archive") + .arg(age_archive()); command } @@ -36,6 +43,15 @@ fn assert_installed_layout(prefix: &Path) { PathBuf::from("share/licenses/gitveil/SOPS-NOTICE.txt"), 0o644, ), + ( + PathBuf::from("share/licenses/gitveil/AGE-BSD-3-Clause.txt"), + 0o644, + ), + ( + PathBuf::from("share/licenses/gitveil/AGE-NOTICE.txt"), + 0o644, + ), + (PathBuf::from("libexec/gitveil/age-keygen"), 0o755), ]; for (relative, expected_mode) in files { let installed = prefix.join(relative); @@ -65,8 +81,49 @@ fn assert_installed_layout(prefix: &Path) { "SOPS notice must link the {version} source tree" ); + let installed_age_license = + fs::read(prefix.join("share/licenses/gitveil/AGE-BSD-3-Clause.txt")) + .expect("installed age license"); + let tracked_age_license = + fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("licenses/AGE-BSD-3-Clause.txt")) + .expect("tracked age license"); + assert_eq!(installed_age_license, tracked_age_license); + + let age_notice = fs::read_to_string(prefix.join("share/licenses/gitveil/AGE-NOTICE.txt")) + .expect("installed age notice"); + assert!(age_notice.contains(support::AGE_KEYGEN_VERSION)); + assert!(age_notice.contains(&format!( + "FiloSottile/age/tree/v{}", + support::AGE_KEYGEN_VERSION + ))); + let output = command_output(Command::new(prefix.join("bin/gitveil")).arg("--version")); assert_success(output, "installed Gitveil version check"); + let output = + command_output(Command::new(prefix.join("libexec/gitveil/age-keygen")).arg("--version")); + assert_success(output, "installed age-keygen version check"); + + let identity = prefix.join("identity-contract.txt"); + let output = assert_success( + command_output( + Command::new(prefix.join("bin/gitveil")) + .env_remove("AGE_KEYGEN_BIN") + .args(["identity", "generate", "--output"]) + .arg(&identity), + ), + "installed identity generation", + ); + let private = fs::read(&identity).expect("installed generated identity"); + assert_no_private_identity_output(&output, &private); + assert_eq!( + fs::metadata(&identity) + .expect("installed identity metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + fs::remove_file(identity).expect("remove installed generated identity"); } #[test] @@ -134,6 +191,8 @@ fn local_installer_rejects_a_directory_destination_without_partial_replacement() b"existing-sidecar" ); assert!(prefix.join("bin/gitveil").is_dir()); + assert!(!prefix.join("libexec/gitveil/age-keygen").exists()); + assert!(!prefix.join("share/licenses/gitveil/LICENSE").exists()); } #[test] @@ -154,6 +213,10 @@ fn release_packaging_rejects_a_sidecar_with_the_wrong_checksum_without_an_archiv .arg(assert_cmd::cargo::cargo_bin!("gitveil")) .arg("--sops-bin") .arg(&fake_sops) + .arg("--age-keygen-bin") + .arg(age_keygen_binary()) + .arg("--age-keygen-archive") + .arg(age_archive()) .arg("--output-dir") .arg(&output_dir), ); @@ -169,3 +232,100 @@ fn release_packaging_rejects_a_sidecar_with_the_wrong_checksum_without_an_archiv "failed packaging must emit no archive" ); } + +#[test] +fn release_packaging_rejects_a_supplied_age_keygen_without_its_verified_archive() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let temporary = tempfile::tempdir().expect("package fixture"); + let output_dir = temporary.path().join("output"); + + let output = command_output( + Command::new("python3") + .current_dir(root) + .env("PYTHONDONTWRITEBYTECODE", "1") + .env("https_proxy", "http://127.0.0.1:9") + .arg("scripts/package-release.py") + .arg("--skip-build") + .arg("--binary") + .arg(assert_cmd::cargo::cargo_bin!("gitveil")) + .arg("--sops-bin") + .arg(sops_binary()) + .arg("--age-keygen-bin") + .arg(age_keygen_binary()) + .arg("--output-dir") + .arg(&output_dir), + ); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("--age-keygen-archive is required with --age-keygen-bin") + ); + assert!(!output_dir.exists()); +} + +#[test] +fn release_packaging_rejects_an_age_keygen_with_the_wrong_checksum() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let temporary = tempfile::tempdir().expect("package fixture"); + let fake_age_keygen = temporary.path().join("age-keygen"); + fs::write(&fake_age_keygen, b"not the official age-keygen artifact").expect("fake age-keygen"); + let output_dir = temporary.path().join("output"); + + let output = command_output( + Command::new("python3") + .current_dir(root) + .env("PYTHONDONTWRITEBYTECODE", "1") + .arg("scripts/package-release.py") + .arg("--skip-build") + .arg("--binary") + .arg(assert_cmd::cargo::cargo_bin!("gitveil")) + .arg("--sops-bin") + .arg(sops_binary()) + .arg("--age-keygen-bin") + .arg(&fake_age_keygen) + .arg("--age-keygen-archive") + .arg(age_archive()) + .arg("--output-dir") + .arg(&output_dir), + ); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("age-keygen checksum mismatch"), + "packaging must fail on the age-keygen checksum" + ); + assert!( + !output_dir.exists(), + "failed packaging must emit no archive" + ); +} + +#[test] +fn release_packaging_rejects_an_unverified_age_archive() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let temporary = tempfile::tempdir().expect("package fixture"); + let fake_archive = temporary.path().join("age.tar.gz"); + fs::write(&fake_archive, b"not the official age archive").expect("fake age archive"); + let output_dir = temporary.path().join("output"); + + let output = command_output( + Command::new("python3") + .current_dir(root) + .env("PYTHONDONTWRITEBYTECODE", "1") + .env("https_proxy", "http://127.0.0.1:9") + .arg("scripts/package-release.py") + .arg("--skip-build") + .arg("--binary") + .arg(assert_cmd::cargo::cargo_bin!("gitveil")) + .arg("--sops-bin") + .arg(sops_binary()) + .arg("--age-keygen-bin") + .arg(age_keygen_binary()) + .arg("--age-keygen-archive") + .arg(&fake_archive) + .arg("--output-dir") + .arg(&output_dir), + ); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("age checksum mismatch")); + assert!(!output_dir.exists()); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs index eeda8e4..aaf0cec 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -19,7 +19,7 @@ const PROCESS_TIMEOUT: Duration = Duration::from_secs(30); /// executes the fetched binary rather than trusting either declaration. pub const SOPS_COMPATIBILITY_BASELINE: (u64, u64, u64) = (3, 13, 2); -/// The checksum-pinned age-keygen build used by the public quickstart contract. +/// The checksum-pinned age-keygen build used by identity and packaging contracts. pub const AGE_KEYGEN_VERSION: &str = "1.3.1"; fn test_tool_sops(version: (u64, u64, u64)) -> PathBuf { @@ -36,7 +36,7 @@ pub fn sops_binary() -> PathBuf { test_tool_sops(SOPS_VERSION) } -/// Resolves the checksum-pinned age-keygen executable used by quickstart tests. +/// Resolves the checksum-pinned age-keygen executable used by integration tests. pub fn age_keygen_binary() -> PathBuf { if let Some(path) = std::env::var_os("AGE_KEYGEN_BIN") { return PathBuf::from(path); @@ -47,6 +47,28 @@ pub fn age_keygen_binary() -> PathBuf { .join("age-keygen") } +/// Resolves the checksum-pinned official archive containing the test age-keygen executable. +/// +/// # Panics +/// Panics if the configured test executable path has no parent directory. +pub fn age_archive() -> PathBuf { + if let Some(path) = std::env::var_os("AGE_KEYGEN_ARCHIVE") { + return PathBuf::from(path); + } + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + let filename = format!("age-v{AGE_KEYGEN_VERSION}-darwin-amd64.tar.gz"); + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + let filename = format!("age-v{AGE_KEYGEN_VERSION}-darwin-arm64.tar.gz"); + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + let filename = format!("age-v{AGE_KEYGEN_VERSION}-linux-amd64.tar.gz"); + #[cfg(all(target_os = "linux", target_arch = "aarch64"))] + let filename = format!("age-v{AGE_KEYGEN_VERSION}-linux-arm64.tar.gz"); + age_keygen_binary() + .parent() + .expect("age-keygen test tool directory") + .join(filename) +} + /// Resolves the compatibility-baseline SOPS build used to author legacy ciphertext. pub fn sops_baseline_binary() -> PathBuf { if let Some(path) = std::env::var_os("SOPS_BASELINE_BIN") { @@ -356,6 +378,21 @@ pub fn contains(haystack: &[u8], needle: &[u8]) -> bool { .any(|window| window == needle) } +/// Asserts that process output contains neither an age secret marker nor the actual secret line. +/// +/// # Panics +/// Panics if the fixture is not an age identity or protected material appears in process output. +pub fn assert_no_private_identity_output(output: &Output, identity: &[u8]) { + let secret_line = identity + .split(|byte| *byte == b'\n') + .find(|line| line.starts_with(b"AGE-SECRET-KEY-")) + .expect("generated identity secret line"); + for stream in [&output.stdout, &output.stderr] { + assert!(!contains(stream, b"AGE-SECRET-KEY-")); + assert!(!contains(stream, secret_line)); + } +} + /// Extracts the `ENC[...]` payload of one encrypted leaf from a SOPS YAML document. /// /// # Panics