diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 056be93..a931304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,23 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: shellcheck install.sh + # A fixed release of ShellCheck, checked by its SHA-256. Dependabot + # does not update it. Update it by hand: the version and the SHA-256 + # of shellcheck-VERSION.linux.x86_64.tar.xz together. + env: + SHELLCHECK_VERSION: v0.11.0 + SHELLCHECK_SHA256: 8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 + run: | + set -euo pipefail + dir="${RUNNER_TEMP}/shellcheck" + mkdir -p "$dir" + curl --proto '=https' --tlsv1.2 -fsSL --retry 3 -o "$dir/shellcheck.tar.xz" \ + "https://github.com/koalaman/shellcheck/releases/download/${SHELLCHECK_VERSION}/shellcheck-${SHELLCHECK_VERSION}.linux.x86_64.tar.xz" + echo "${SHELLCHECK_SHA256} $dir/shellcheck.tar.xz" | sha256sum --strict -c - + tar -xJf "$dir/shellcheck.tar.xz" -C "$dir" + "$dir/shellcheck-${SHELLCHECK_VERSION}/shellcheck" --version + "$dir/shellcheck-${SHELLCHECK_VERSION}/shellcheck" --shell=sh install.sh - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod diff --git a/AGENTS.md b/AGENTS.md index 6dc0c81..71d2fe2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,14 @@ rules the code must follow. Planned. Each v1 issue adds its part. +- `install.sh` - the installer: it installs a release on a host, as root, + after it checks the release (DESIGN.md section 18). `install.go` + (package `nodkey`, at the root of the module) builds it into the + binary with `go:embed`, for `nodkey update`. The tests of the script + are in `install_linux_test.go`. CI checks it with shellcheck. - `cmd/nodkey/` - the one binary. Subcommands: `broker`, `run`, - `ssh-agent`, `ssh-grant`, `keygen`, `audit verify`, `version`. + `ssh-agent`, `ssh-grant`, `keygen`, `audit verify`, `update`, + `version`. `nodkey broker` starts the broker's parts in a fixed order, and stops them in a fixed order (DESIGN.md section 15). Its tests run it in a child process, with a fake Telegram server behind a fake HTTPS proxy. diff --git a/DESIGN.md b/DESIGN.md index a744140..7d55b11 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -89,6 +89,7 @@ One static Go binary holds all parts. The subcommand picks the role: | `nodkey ssh-grant` | agent machine | Ask once for SSH logins to a list of hosts for N minutes (see [decision 4](#13-decision-4-ssh-approval-scope)). | | `nodkey keygen` | both | Make a TLS key pair for the broker or a client. Print its pin. | | `nodkey audit verify` | trusted machine | Check the hash chain of an audit log. | +| `nodkey update` | both, as root | Install a release in place of this binary, with the install script that is built into it (section 18). | | `nodkey version` | both | Print the version. | Inside the broker, the approver and the stores sit behind small Go @@ -2040,6 +2041,7 @@ nodkey ssh-agent [--socket PATH] nodkey ssh-grant --key NAME --user USER --host NAME... --for DURATION [--reason TEXT] nodkey keygen --out FILE nodkey audit verify FILE [--anchor SEQ:HASH]... [--anchors FILE|-] +nodkey update [--version X.Y.Z] [--gh PATH] [--reinstall] nodkey version ``` @@ -2324,6 +2326,37 @@ text of a line. It exits with: | 65 | the log failed: a broken link, a line that is not a valid entry, a torn line without its `log_recovered` entry, an anchor that does not match or whose entry is not in the log, or two anchors for one entry that differ | | 66 | the log or the anchors file cannot be opened or read | +`nodkey update [--version X.Y.Z] [--gh PATH] [--reinstall]` installs a +release in place of the file that it runs from: the newest release, or +`X.Y.Z`, also an older one. It runs only as root. Otherwise it prints +`nodkey: nodkey update must run as root: run sudo nodkey update (exit 1)`. +It runs the install script that is built into the binary (`install.sh`, +section 18) with `/bin/sh`. The script gets `--bin-dir` with the +directory of the file of nodkey, after links, and the flags that are +given: `--version`, `--gh` and `--reinstall`. No other value goes into +its arguments. Its stdin is `/dev/null`, and it writes to the stdout and +stderr of nodkey. So the code that checks a new release comes from the +build that is installed and was checked, not fresh from the network. A +file that is not named `nodkey` is refused: the script would replace +another file. `--version` must be `X.Y.Z`, such as `0.2.0`, and `--gh` +an absolute path; each may be given once. + +The script gets a fixed environment: `PATH=/usr/sbin:/usr/bin:/sbin:/bin`, +`LC_ALL=C` and `HOME=/root`, and `HTTPS_PROXY`, `https_proxy`, `NO_PROXY` +and `no_proxy` if nodkey has them. Nothing else comes from the environment +of nodkey. With `sudo -E`, or after `su` without `-`, the environment of +root can come from another user, and variables such as `TAR_OPTIONS`, +`LD_PRELOAD` or `TMPDIR` would change what the programs of the script do +as root. Without `TMPDIR`, the script uses `/tmp`. + +`install.sh` and `nodkey update` exit with: + +| code | meaning | +|---|---| +| 0 | the release is installed, or there is nothing to do: that version is installed already, or the installed one is newer and `--version` does not ask for the older one | +| 1 | any failure; nothing was installed. The last line on stderr is `nodkey: ; nothing was installed (exit 1)` | +| 64 | wrong usage | + ## 16. Implementation rules - No secret value in any log, error text, audit entry or Telegram message. @@ -2618,7 +2651,8 @@ bws 2.1.0 and bw 2026.9.1 on 2026-10-14 ## 18. Build, CI and releases - CI on every pull request: `go test -race`, `go vet`, `golangci-lint`, - `govulncheck`, and the end-to-end job. + `govulncheck`, `shellcheck` on `install.sh`, and the end-to-end job. + ShellCheck is a fixed release, checked by its SHA-256. - GitHub Actions are pinned to a commit SHA. - Pull request titles follow Conventional Commits. Pull requests are squash merged. @@ -2628,6 +2662,83 @@ bws 2.1.0 and bw 2026.9.1 on 2026-10-14 `SHA256SUMS` file, and a build provenance attestation. The attestation is also a file of the release (`nodkey_.sigstore.json`), so it can be checked with no GitHub login. +- `install.sh`, at the root of the repository, installs a release on the + host itself, as root: the broker host or an agent machine. It needs + `sh`, `curl`, `tar`, `gzip`, `sha256sum`, `install`, `mktemp`, `stat` + and `uname`, and no GitHub login. `nodkey update` runs the copy that is + built into nodkey with `go:embed` (section 15). First, the script makes + its environment safe. It removes the variables that change what its + programs do: `TAR_OPTIONS` (tar can run commands from it), `GZIP`, + `POSIXLY_CORRECT`, `CURL_HOME`, `CURL_CA_BUNDLE`, `SSL_CERT_FILE`, + `SSL_CERT_DIR`, `SSLKEYLOGFILE`, `OPENSSL_CONF`, `OPENSSL_MODULES`, + `OPENSSL_ENGINES` (an OpenSSL config can name a library for curl to + load), `LD_PRELOAD`, `LD_LIBRARY_PATH`, `LD_AUDIT`, `GCONV_PATH`, + `CDPATH` and `IFS`. It sets `LC_ALL=C`. As + root, it sets `PATH=/usr/sbin:/usr/bin:/sbin:/bin`. curl always gets + `-q`, so it reads no `.curlrc`. Then the script: + 1. runs only on Linux, on amd64 or arm64; + 2. checks the places that it runs programs from and writes to, before + it downloads anything: the bin directory, the old `nodkey` in it, + the gh of `--gh`, and the temporary directory (`TMPDIR`, or `/tmp`). + Each of them, and each directory above it up to `/`, must be owned + by root, and group and others must not be able to write to it. A + directory above them, and `TMPDIR` itself, may let others write only + with the sticky bit, as `/tmp` does. Links in the path are resolved + once, and the script uses the real path from then on. The old file + and the gh must be files, not links. When the script does not run + as root, the user who runs it may own these paths too: the tests run + it so; + 3. takes the version of `--version`, or asks the GitHub API for the + newest release, with no login; only a tag `vX.Y.Z` counts; + 4. stops when the installed `nodkey version` says that version already, + unless `--reinstall` is given. It says that it did not check the + installed file. Without `--version`, it does not install a version + older than the installed one. A build from the source (`dev`, or not + `X.Y.Z`) has an unknown version, and any release replaces it; + 5. downloads the tarball for the machine, `SHA256SUMS`, and the + Sigstore bundle of the attestation, `nodkey_.sigstore.json`, + over HTTPS only, into a new private temporary directory. Each + download has a size limit: 64 MiB for the tarballs (of nodkey and of + gh), 1 MiB for the other files; + 6. checks the SHA-256 of the tarball against its own line in + `SHA256SUMS`; + 7. gets `gh`: it downloads gh 2.102.0 and checks it against the SHA-256 + in the script, one for each CPU, or it takes the gh of `--gh PATH`. + That gh must be 2.93.0 or newer: older ones have known bugs in + `attestation verify` (GHSA-fgw4-v983-mgp8, GHSA-8xvp-7hj6-mcj9). The + first line of `gh --version` must be `gh version X.Y.Z`, so a version + such as `2.93.0-rc.1` does not count. Newer advisories + (GHSA-mm27-mwq9-fr5g, GHSA-wjmr-j3rp-mh2g, GHSA-4mq3-hpgx-9cx8) are + about `--signer-repo`, `--signer-workflow` and `--source-ref`, which + the script does not use; + 8. runs `gh attestation verify --bundle` with the identity of the + release workflow on `main` (as in + [docs/releasing.md](docs/releasing.md#verify-a-download)) and + `--deny-self-hosted-runners`. gh gets no login and an empty config: + no `GH_TOKEN`, `GITHUB_TOKEN`, `GH_HOST`, `GH_ENTERPRISE_TOKEN` or + `GITHUB_ENTERPRISE_TOKEN`, and `HOME`, `GH_CONFIG_DIR` and the + `XDG_*` directories in the temporary directory. Its update notices, + prompts and telemetry are off. It still gets the trusted root of + Sigstore online; + 9. takes only `nodkey` out of the tarball, copies it under a new name + into the directory of the old file, and checks that its + `nodkey version` prints exactly `nodkey `. The attestation + proves which workflow built a file, not which version the file is. + Without this check, an attested older build under a newer name + would pass. The new nodkey runs only after the attestation passed; + 10. renames the new file over `/usr/local/bin/nodkey` (or `DIR/nodkey` + for `--bin-dir DIR`), with mode `0755`, owned by root when the + script runs as root. A rename in one directory replaces the old + file in one step, never half; + 11. says what to restart: the broker (`systemctl restart nodkey`, which + ends all grants) and each `nodkey ssh-agent`. It restarts nothing + itself; + 12. removes the temporary directory, also after a failure or a signal. + + Any failed step stops the script with a clear last line, and nothing + is installed. All the work is in functions, and only the last line + starts it, so a download through `curl ... | sh` that stops in the + middle runs nothing. THREAT-MODEL.md T20 says what the script trusts. ## 19. How the work is done diff --git a/README.md b/README.md index 1885fda..29df82f 100644 --- a/README.md +++ b/README.md @@ -50,35 +50,31 @@ clipboard, terminals and browser. Keep no SSH key or other way to get root on the broker host on the agent machine ([docs/setup.md section 1](docs/setup.md#where-you-do-the-setup)). -1. **Install** `nodkey` on both machines. Get it on a trusted machine, - never on the agent machine. The easy way is the - [latest release](https://github.com/code6x/nodkey/releases/latest): - download the tarball for the CPU of each machine, such as - `nodkey_0.1.0_linux_amd64.tar.gz` or `nodkey_0.1.0_linux_arm64.tar.gz`, - and `SHA256SUMS`. Check them as - [docs/releasing.md](docs/releasing.md#verify-a-download) says, and - take the file `nodkey` out of the tarball: +1. **Install** `nodkey` on both machines, as root on each machine. Never + download, build or copy it as the user of the agent. Get the root + shell in a way that does not go through the agent's account: on the + broker host, its console or a trusted computer; on the agent machine, + its console or SSH as an admin user from a trusted computer, not + `sudo` in the terminal where the agent works. (nodkey's promises come + from the broker, which trusts nothing from the client: a changed + client gets no more than what you approve on the phone.) + + The easy way is the installer. It installs the newest release as + `/usr/local/bin/nodkey`. First it checks the SHA-256 and the build + attestation of the release, and the version of the new file. Download + it as root, read it, and run it as root: ```sh - tar -xzf nodkey_0.1.0_linux_amd64.tar.gz nodkey + sudo curl --proto '=https' --tlsv1.2 -fsSL -o /root/install.sh https://raw.githubusercontent.com/code6x/nodkey/main/install.sh + sudo less /root/install.sh + sudo sh /root/install.sh ``` - If the two machines have different CPUs, take each file out in a - folder of its own, and copy each to its machine. - - Or build it from this repository with Go 1.26 or newer: - - ```sh - CGO_ENABLED=0 go build -trimpath -o nodkey ./cmd/nodkey - ``` - - Copy the file `nodkey` straight to both machines, not through the - agent machine. On the broker host, `sha256sum nodkey` must print the - same SHA-256 as on the trusted machine. Install it as root: - - ```sh - install -o root -g root -m 0755 nodkey /usr/local/bin/nodkey - ``` + The installer needs the Sigstore bundle of the attestation in the + release, and 0.1.0 has none. For 0.1.0, get nodkey by hand on a + trusted machine, check it, and copy it to both machines, as + [docs/setup.md section 2](docs/setup.md#2-get-nodkey) says. Later, + `nodkey update`, as root, installs a new release. 2. **Make a Telegram bot** with [@BotFather](https://t.me/BotFather) (`/newbot`), on your phone or a trusted computer. Open the bot and diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index c05cb53..3e58951 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -482,6 +482,50 @@ Risk that remains: low. **T20. A dependency or the build is tampered with.** Few dependencies. Actions pinned to commit SHAs. `govulncheck` in CI. Release binaries come with `SHA256SUMS` and a build provenance attestation. +The attestation proves which workflow built a file, not which version the +file is: `gh attestation verify` compares only the SHA-256 of the file. An +attested build of 0.1.0, renamed as 9.9.9, still passes. So a check must +also run `nodkey version` of the new file, and compare it with the version +it wants (step 3 of +[docs/releasing.md, "Verify a download"](docs/releasing.md#verify-a-download)). + +The installer, `install.sh`, and `nodkey update` do all these checks +before they install anything (DESIGN.md section 18). What they trust: +- The first install runs `install.sh` from `main`, as GitHub serves it. + Whoever can change `main` can change the script. But they can also + change the code of the next release, which the release workflow then + builds and attests. So the script from `main` is as trusted as a build + from `main`. A person can read the script before running it, or install + by hand ([docs/setup.md section 2](docs/setup.md#2-get-nodkey)). +- An update with `nodkey update` runs the copy of the script that is + built into the installed nodkey. That build was checked when it was + installed. So nothing fresh from the network decides how a new release + is checked. +- Both trust the gh that checks the attestation: gh 2.102.0, downloaded + and checked against the SHA-256 in the script (its tarballs passed + `gh attestation verify --repo cli/cli` when it was pinned), or the gh of + `--gh`. They trust Sigstore: its trusted root, which gh gets online, + and its certificate authority. They trust GitHub to say truthfully which + workflow, on which branch and on which kind of runner, signed: the + identity `.github/workflows/release.yml@refs/heads/main` and + `--deny-self-hosted-runners`. +- Both trust root on the host where they run, and the way the person got + root. As root, the script runs only the programs of the system, removes + the variables that change what they do (such as `TAR_OPTIONS`), and + refuses a bin directory, an old file, a gh of `--gh` or a temporary + directory that another user can change. `nodkey update` gives the + script a fixed environment. But a root shell that comes through the + account of the agent, such as `sudo` in the agent's terminal, trusts + that account: the agent can put its own `sudo` first in `PATH`, or type + into the terminal. So on an agent machine, get root on the console, or + with SSH as an admin user from a trusted computer + ([docs/setup.md section 2](docs/setup.md#2-get-nodkey)). Never set up + or update the broker host through an agent machine. +- On an agent machine that the agent controls, nodkey cannot keep its + own client safe (section 3: the agent may be root there). The broker + trusts nothing from the client, so a changed client gets no more than + what the person approves on the phone. + The `bws` and `bw` programs are outside nodkey. They can be attacked too: a malicious `@bitwarden/cli` 2026.4.0 was on npm for about 90 minutes on 2026-04-22 @@ -494,11 +538,26 @@ Risk that remains: - They can create a new `v*` tag and publish a release with their own files and their own `SHA256SUMS`, with no pull request and no CI. Only the build attestation shows that the release workflow did not build - these files (`gh attestation verify`, see + these files. The installer and `nodkey update` refuse such a release. + A check by hand must run `gh attestation verify` (see [docs/releasing.md](docs/releasing.md#verify-a-download)). + - They can publish a new tag with the attested files of an older + release under new names, such as the files and the bundle of 0.2.0 + as 0.3.0. The checksum and the attestation pass, and an old build, + maybe one with a known security bug, would be installed. The + installer and `nodkey update` refuse it: the new nodkey does not say + that it is the version of the tag. A check by hand must run + `nodkey version`. + - They can make an older release the newest one on GitHub. Then + `nodkey update` installs nothing, as it never installs an older + version without `--version`. But a first install gets that older + release. The installer prints the version that it installs. + - A release that the installer refuses also stops `nodkey update` + without `--version`, until a good newer release comes. - They can open a pull request, merge it once CI passes (`main` needs no human approval), and get it released. Such a release even has a valid - attestation. + attestation, and the installer installs it. In the same way they can + change `install.sh` on `main`, which a first install runs. The rulesets still stop them from moving or deleting a `v*` tag, and immutable releases stop changes to a published release. @@ -805,10 +864,17 @@ apps, and it cannot check where an account is logged in. where no agent runs. Its tokens and passwords are made and copied only there or on the phone, never through an agent machine. No agent machine holds a way to get root on the broker host, such as an SSH - key, an SSH agent or a `ControlMaster` socket. The nodkey binary comes - to the broker host straight from where it was built or downloaded, and - its SHA-256 is checked there + key, an SSH agent or a `ControlMaster` socket. +- Root installs the nodkey binary on each host, from a root shell that + does not come through the account of the agent, and the user of the + agent never downloads, builds or copies it. Root runs `install.sh` or + `nodkey update` on the host itself, which check the release (T20). Or + the binary comes straight from a trusted machine where it was built or + downloaded and checked, and its SHA-256 is checked on the broker host ([docs/setup.md section 1](docs/setup.md#where-you-do-the-setup)). +- Only root can change the nodkey binary and its directory, such as + `/usr/local/bin`. `nodkey update` runs, as root, the script that is + built into that binary. - The approver's Telegram account is not logged in on any agent machine. - No Bitwarden account that can read the secrets of the stores is logged in on any agent machine, and no agent machine can read its master diff --git a/cmd/nodkey/main.go b/cmd/nodkey/main.go index 1c4a678..f0ec2ef 100644 --- a/cmd/nodkey/main.go +++ b/cmd/nodkey/main.go @@ -39,6 +39,8 @@ Commands: ssh-grant ask once for SSH logins to a list of hosts, for some minutes audit verify FILE [--anchor SEQ:HASH]... [--anchors FILE|-] check the hash chain of an audit log, and its anchors + update [--version X.Y.Z] [--gh PATH] [--reinstall] + install a release in place of this nodkey; run it as root version print the version help print this help ` @@ -76,6 +78,8 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { return runSSHAgent(rest, stderr) case "ssh-grant": return runSSHGrant(rest, stderr) + case "update": + return runUpdate(rest, stdout, stderr) case "version": return runVersion(rest, stdout, stderr) case "help": diff --git a/cmd/nodkey/update.go b/cmd/nodkey/update.go new file mode 100644 index 0000000..11e8191 --- /dev/null +++ b/cmd/nodkey/update.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + + "github.com/code6x/nodkey" +) + +const updateUsage = `Usage: nodkey update [--version X.Y.Z] [--gh PATH] [--reinstall] + +Install the newest release of nodkey in place of the file of this nodkey, +or the release X.Y.Z. Run it as root: sudo nodkey update. + +It runs the install script that is built into this nodkey (install.sh of +the repository). So the code that checks the new release comes from the +build that you installed and checked before, not fresh from the network. +Before it replaces the file, the script checks the SHA-256 and the build +attestation of the release, and the version of the new nodkey. It +restarts nothing. Then restart the broker (systemctl restart nodkey, +which ends all grants) and each nodkey ssh-agent. + + --version X.Y.Z install this release, also one older than this nodkey + --gh PATH check the attestation with this gh, 2.93.0 or newer, + which only root can change (default: download gh and + check its SHA-256) + --reinstall install also when this version is installed already + +Exit codes: 0 done or nothing to do, 1 failure, 64 wrong usage. +` + +// scriptPath is the PATH of the install script: only the programs of the +// system. +const scriptPath = "/usr/sbin:/usr/bin:/sbin:/bin" + +// proxyVars are the only variables of the environment of nodkey that the +// install script gets, if they are set: curl and gh may need a proxy to +// reach GitHub. +var proxyVars = []string{"HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"} + +// The parts of nodkey update that a test replaces. nodkey never changes +// them. +var ( + // geteuid returns the effective user ID, so that a test can run + // nodkey update as if it were root. + geteuid = os.Geteuid + // executable returns the path of this program, as os.Executable does. + executable = os.Executable + // runInstallScript runs the install script with the arguments and the + // environment, and returns its exit code, as runScript does. + runInstallScript = runScript +) + +// versionPattern is a version X.Y.Z, as install.sh accepts it: three +// numbers of at most 9 digits, without a leading zero. +var versionPattern = regexp.MustCompile(`^(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})\.(0|[1-9][0-9]{0,8})$`) + +// checkedFlag is a flag of nodkey update that may be given once, and +// whose value must pass check. +type checkedFlag struct { + value string + set bool + check func(string) error +} + +func (f *checkedFlag) String() string { return f.value } + +func (f *checkedFlag) Set(s string) error { + if f.set { + return errors.New("give it once") + } + if err := f.check(s); err != nil { + return err + } + f.value, f.set = s, true + return nil +} + +// runUpdate runs nodkey update (DESIGN.md section 15). As root, it runs +// the install script that is built into this binary, so that the script +// replaces the file of this program with a release. +func runUpdate(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("nodkey update", flag.ContinueOnError) + flags.SetOutput(stderr) + flags.Usage = func() { _, _ = io.WriteString(stderr, updateUsage) } + want := checkedFlag{check: func(s string) error { + if !versionPattern.MatchString(s) { + return errors.New("want X.Y.Z, such as 0.2.0") + } + return nil + }} + flags.Var(&want, "version", "install the release `X.Y.Z`") + gh := checkedFlag{check: func(s string) error { + if !filepath.IsAbs(s) { + return errors.New("want an absolute path") + } + return nil + }} + flags.Var(&gh, "gh", "check the attestation with the gh at `PATH`") + reinstall := flags.Bool("reinstall", false, "install also when this version is installed already") + if err := flags.Parse(args); err != nil { + return parseErrorCode(err) + } + if flags.NArg() > 0 { + flags.Usage() + return exitUsage + } + if geteuid() != 0 { + return fail(stderr, exitFailure, "nodkey update must run as root: run sudo nodkey update") + } + exe, err := executable() + if err != nil { + return fail(stderr, exitFailure, fmt.Sprintf("cannot find the file of this program: %v", err)) + } + // The script replaces DIR/nodkey. When the program was started + // through a link, that is the file that the link points to. + path, err := filepath.EvalSymlinks(exe) + if err != nil { + return fail(stderr, exitFailure, fmt.Sprintf("cannot find the file of this program: %v", err)) + } + if filepath.Base(path) != "nodkey" { + return fail(stderr, exitFailure, fmt.Sprintf("nodkey update replaces only a file named nodkey, and this program is %s", path)) + } + // Only these arguments go to the script, and nothing from the + // environment. The script checks that only root can change the + // directory, the old file and the gh. + scriptArgs := []string{"--bin-dir", filepath.Dir(path)} + if want.set { + scriptArgs = append(scriptArgs, "--version", want.value) + } + if gh.set { + scriptArgs = append(scriptArgs, "--gh", gh.value) + } + if *reinstall { + scriptArgs = append(scriptArgs, "--reinstall") + } + return runInstallScript(nodkey.InstallScript, scriptArgs, scriptEnv(), stdout, stderr) +} + +// scriptEnv returns the environment of the install script. It is fixed: +// with sudo -E, or after su without -, the environment of root can come +// from another user, and variables such as TAR_OPTIONS, LD_PRELOAD or +// TMPDIR would change what the programs of the script do as root. Only +// the proxy variables of nodkey go on, if they are set. +func scriptEnv() []string { + env := []string{"PATH=" + scriptPath, "LC_ALL=C", "HOME=/root"} + for _, name := range proxyVars { + if value, ok := os.LookupEnv(name); ok { + env = append(env, name+"="+value) + } + } + return env +} + +// runScript runs script with /bin/sh, with "nodkey-install" as $0, args as +// its arguments and env as its environment. stdin is /dev/null, and stdout +// and stderr are those of nodkey. It returns the exit code of the script. +func runScript(script string, args, env []string, stdout, stderr io.Writer) int { + cmd := exec.CommandContext(context.Background(), "/bin/sh", append([]string{"-c", script, "nodkey-install"}, args...)...) //nolint:gosec // G204: the script is built into nodkey; the arguments are --bin-dir with the directory of this program, and the checked flags. + cmd.Env = env + cmd.Stdout = stdout + cmd.Stderr = stderr + err := cmd.Run() + var exit *exec.ExitError + switch { + case err == nil: + return exitOK + case errors.As(err, &exit) && exit.ExitCode() >= 0: + return exit.ExitCode() + case errors.As(err, &exit): + return fail(stderr, exitFailure, "a signal stopped the install script") + default: + return fail(stderr, exitFailure, fmt.Sprintf("cannot run the install script: %v", err)) + } +} diff --git a/cmd/nodkey/update_test.go b/cmd/nodkey/update_test.go new file mode 100644 index 0000000..baa8e3b --- /dev/null +++ b/cmd/nodkey/update_test.go @@ -0,0 +1,282 @@ +package main + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/code6x/nodkey" +) + +// scriptRun is what nodkey update gave to the install script. +type scriptRun struct { + calls int + script string + args []string + env []string +} + +// fakeUpdate replaces the parts of nodkey update for one test: the +// effective user ID, the path of the program, and the run of the script, +// which returns code. It returns what the script got. +func fakeUpdate(t *testing.T, euid int, exe string, code int) *scriptRun { + t.Helper() + oldEuid, oldExe, oldRun := geteuid, executable, runInstallScript + t.Cleanup(func() { geteuid, executable, runInstallScript = oldEuid, oldExe, oldRun }) + got := &scriptRun{} + geteuid = func() int { return euid } + executable = func() (string, error) { return exe, nil } + runInstallScript = func(script string, args, env []string, _, _ io.Writer) int { + got.calls++ + got.script, got.args, got.env = script, args, env + return code + } + return got +} + +// installedNodkey makes a file named nodkey in a directory of its own, and +// a link to it in another directory, as /usr/local/bin/nodkey could be. +// It returns the path of the link and the directory of the file. +func installedNodkey(t *testing.T) (link, dir string) { + t.Helper() + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + dir = filepath.Join(root, "opt") + link = filepath.Join(root, "bin", "nodkey") + for _, d := range []string{dir, filepath.Dir(link)} { + if err := os.Mkdir(d, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "nodkey"), []byte("a made-up program\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(dir, "nodkey"), link); err != nil { + t.Fatal(err) + } + return link, dir +} + +func TestUpdateUsage(t *testing.T) { + for _, tt := range []struct { + name string + args []string + code int + want string // a part of stderr + }{ + {"an argument", []string{"update", "0.2.0"}, exitUsage, ""}, + {"an unknown flag", []string{"update", "--bin-dir", "/tmp"}, exitUsage, "flag provided but not defined: -bin-dir"}, + {"no version", []string{"update", "--version"}, exitUsage, "flag needs an argument: -version"}, + {"an empty version", []string{"update", "--version="}, exitUsage, "want X.Y.Z, such as 0.2.0"}, + {"a version with two numbers", []string{"update", "--version", "0.2"}, exitUsage, "want X.Y.Z"}, + {"a version with a v", []string{"update", "--version", "v0.2.0"}, exitUsage, "want X.Y.Z"}, + {"a version with a leading zero", []string{"update", "--version", "0.02.0"}, exitUsage, "want X.Y.Z"}, + {"a version that is a flag", []string{"update", "--version", "--gh"}, exitUsage, "want X.Y.Z"}, + {"two versions", []string{"update", "--version", "0.2.0", "--version", "0.3.0"}, exitUsage, "give it once"}, + {"a gh that is not absolute", []string{"update", "--gh", "gh"}, exitUsage, "want an absolute path"}, + {"an empty gh", []string{"update", "--gh="}, exitUsage, "want an absolute path"}, + {"two gh", []string{"update", "--gh", "/usr/bin/gh", "--gh", "/usr/local/bin/gh"}, exitUsage, "give it once"}, + {"a value for --reinstall", []string{"update", "--reinstall=maybe"}, exitUsage, "invalid boolean value"}, + {"-h", []string{"update", "-h"}, exitOK, ""}, + } { + t.Run(tt.name, func(t *testing.T) { + got := fakeUpdate(t, 0, "/usr/local/bin/nodkey", exitOK) + var stdout, stderr bytes.Buffer + if code := run(tt.args, strings.NewReader(""), &stdout, &stderr); code != tt.code { + t.Errorf("exit code %d, want %d", code, tt.code) + } + if !strings.Contains(stderr.String(), "Usage: nodkey update [--version X.Y.Z] [--gh PATH] [--reinstall]") || + !strings.Contains(stderr.String(), tt.want) { + t.Errorf("stderr = %q, want the usage and %q", stderr.String(), tt.want) + } + if stdout.Len() != 0 || got.calls != 0 { + t.Errorf("stdout %q, %d runs of the script; want nothing", stdout.String(), got.calls) + } + }) + } + var stdout bytes.Buffer + if run([]string{"help"}, strings.NewReader(""), &stdout, &bytes.Buffer{}); !strings.Contains(stdout.String(), "\n update [--version X.Y.Z] [--gh PATH] [--reinstall]\n") { + t.Errorf("help does not list update: %q", stdout.String()) + } +} + +// TestUpdateNeedsRoot: as another user, nodkey update says to use sudo, +// and runs nothing. +func TestUpdateNeedsRoot(t *testing.T) { + got := fakeUpdate(t, 1000, "/usr/local/bin/nodkey", exitOK) + var stdout, stderr bytes.Buffer + if code := run([]string{"update"}, strings.NewReader(""), &stdout, &stderr); code != exitFailure { + t.Errorf("exit code %d, want %d", code, exitFailure) + } + if want := "nodkey: nodkey update must run as root: run sudo nodkey update (exit 1)\n"; stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } + if stdout.Len() != 0 || got.calls != 0 { + t.Errorf("stdout %q, %d runs of the script; want nothing", stdout.String(), got.calls) + } +} + +// TestUpdateRunsTheBuiltInScript: as root, nodkey update runs install.sh, +// as it is built in, with --bin-dir set to the directory of the file that +// the program runs from, behind links, with the flags that are given, and +// with a fixed environment. +func TestUpdateRunsTheBuiltInScript(t *testing.T) { + file, err := os.ReadFile(filepath.Join("..", "..", "install.sh")) + if err != nil { + t.Fatal(err) + } + if nodkey.InstallScript != string(file) { + t.Fatal("the built-in script is not install.sh") + } + // The files first: t.TempDir uses TMPDIR, which the test changes. + link, dir := installedNodkey(t) + // The environment of nodkey, as sudo -E could give it: only the + // proxy variables reach the script. + for name, value := range map[string]string{ + "TAR_OPTIONS": "--to-command=/nonexistent", "LD_PRELOAD": "/nonexistent/lib.so", "TMPDIR": "/nonexistent/tmp", + "PATH": "/nonexistent/bin:/usr/bin", "HOME": "/nonexistent/home", "GH_TOKEN": "made-up-token", + "HTTPS_PROXY": "http://192.0.2.1:3128", "no_proxy": "example.com", + } { + t.Setenv(name, value) + } + // Unset, and set back at the end of the test (t.Setenv). + for _, name := range []string{"https_proxy", "NO_PROXY"} { + t.Setenv(name, "") + if err := os.Unsetenv(name); err != nil { + t.Fatal(err) + } + } + wantEnv := []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin", "LC_ALL=C", "HOME=/root", + "HTTPS_PROXY=http://192.0.2.1:3128", "no_proxy=example.com"} + + for _, tt := range []struct { + args []string + want []string + }{ + {nil, []string{"--bin-dir", dir}}, + {[]string{"--version", "0.2.0"}, []string{"--bin-dir", dir, "--version", "0.2.0"}}, + {[]string{"-version=1.10.0"}, []string{"--bin-dir", dir, "--version", "1.10.0"}}, + {[]string{"--version", "0.0.0"}, []string{"--bin-dir", dir, "--version", "0.0.0"}}, + {[]string{"--gh", "/usr/bin/gh"}, []string{"--bin-dir", dir, "--gh", "/usr/bin/gh"}}, + {[]string{"--reinstall"}, []string{"--bin-dir", dir, "--reinstall"}}, + {[]string{"--reinstall=false"}, []string{"--bin-dir", dir}}, + {[]string{"--reinstall", "--gh", "/usr/bin/gh", "--version", "0.2.0"}, + []string{"--bin-dir", dir, "--version", "0.2.0", "--gh", "/usr/bin/gh", "--reinstall"}}, + } { + got := fakeUpdate(t, 0, link, exitOK) + var stdout, stderr bytes.Buffer + if code := run(append([]string{"update"}, tt.args...), strings.NewReader(""), &stdout, &stderr); code != exitOK { + t.Errorf("%q: exit code %d, want 0; stderr %q", tt.args, code, stderr.String()) + } + if got.calls != 1 || got.script != string(file) { + t.Errorf("%q: %d runs of the script; want 1, of install.sh", tt.args, got.calls) + } + if !slices.Equal(got.args, tt.want) { + t.Errorf("%q: the script got %q, want %q", tt.args, got.args, tt.want) + } + if !slices.Equal(got.env, wantEnv) { + t.Errorf("%q: the script got the environment %q, want %q", tt.args, got.env, wantEnv) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Errorf("%q: stdout %q, stderr %q; want nothing", tt.args, stdout.String(), stderr.String()) + } + } +} + +// TestUpdatePassesTheExitCode: nodkey update exits with the exit code of +// the script. +func TestUpdatePassesTheExitCode(t *testing.T) { + link, _ := installedNodkey(t) + for _, code := range []int{exitOK, exitFailure, exitUsage, 3} { + fakeUpdate(t, 0, link, code) + if got := run([]string{"update"}, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}); got != code { + t.Errorf("exit code %d, want %d from the script", got, code) + } + } +} + +// TestUpdateRefusesOtherFiles: nodkey update stops when it cannot find +// the file it runs from, or when that file is not named nodkey: the script +// would replace another file then. +func TestUpdateRefusesOtherFiles(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + other := filepath.Join(root, "nodkey-0.1.0") + if err := os.WriteFile(other, []byte("a made-up program\n"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(root, "nodkey") + if err := os.Symlink(other, link); err != nil { + t.Fatal(err) + } + for _, tt := range []struct { + exe string + want string + }{ + {other, "nodkey update replaces only a file named nodkey, and this program is " + other + " (exit 1)\n"}, + {link, "nodkey update replaces only a file named nodkey, and this program is " + other + " (exit 1)\n"}, + {filepath.Join(root, "gone", "nodkey"), "cannot find the file of this program: "}, + } { + got := fakeUpdate(t, 0, tt.exe, exitOK) + var stderr bytes.Buffer + if code := run([]string{"update"}, strings.NewReader(""), &bytes.Buffer{}, &stderr); code != exitFailure { + t.Errorf("%s: exit code %d, want %d", tt.exe, code, exitFailure) + } + if !strings.HasPrefix(stderr.String(), "nodkey: ") || !strings.Contains(stderr.String(), tt.want) { + t.Errorf("%s: stderr = %q, want %q", tt.exe, stderr.String(), tt.want) + } + if got.calls != 0 { + t.Errorf("%s: the script ran", tt.exe) + } + } + got := fakeUpdate(t, 0, "", exitOK) + executable = func() (string, error) { return "", errors.New("no /proc") } + var stderr bytes.Buffer + if code := run([]string{"update"}, strings.NewReader(""), &bytes.Buffer{}, &stderr); code != exitFailure || got.calls != 0 { + t.Errorf("exit code %d, %d runs; want %d and no run", code, got.calls, exitFailure) + } + if want := "nodkey: cannot find the file of this program: no /proc (exit 1)\n"; stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } +} + +// TestRunScript runs scripts with /bin/sh as nodkey update does: $0 is +// nodkey-install, the arguments come as they are, the environment is only +// the given one, stdin is /dev/null, stdout and stderr are those of +// nodkey, and the exit code comes back. +func TestRunScript(t *testing.T) { + t.Setenv("TAR_OPTIONS", "--to-command=/nonexistent") + var stdout, stderr bytes.Buffer + code := runScript(`printf '%s|' "$0" "$@" "${A-unset}" "${TAR_OPTIONS-unset}"; echo to-stderr >&2; if read -r line; then exit 9; fi; exit 3`, + []string{"--bin-dir", "/a dir/with $HOME", "--version", "0.2.0"}, []string{"A=1"}, &stdout, &stderr) + if code != 3 { + t.Errorf("exit code %d, want 3 (9 means stdin was not empty)", code) + } + if want := "nodkey-install|--bin-dir|/a dir/with $HOME|--version|0.2.0|1|unset|"; stdout.String() != want { + t.Errorf("stdout = %q, want %q", stdout.String(), want) + } + if stderr.String() != "to-stderr\n" { + t.Errorf("stderr = %q", stderr.String()) + } + + stderr.Reset() + if code := runScript("exit 0", nil, scriptEnv(), &bytes.Buffer{}, &stderr); code != exitOK || stderr.Len() != 0 { + t.Errorf("exit 0: exit code %d, stderr %q", code, stderr.String()) + } + stderr.Reset() + if code := runScript("kill -TERM $$", nil, scriptEnv(), &bytes.Buffer{}, &stderr); code != exitFailure { + t.Errorf("a script that a signal stops: exit code %d, want %d", code, exitFailure) + } + if want := "nodkey: a signal stopped the install script (exit 1)\n"; stderr.String() != want { + t.Errorf("stderr = %q, want %q", stderr.String(), want) + } +} diff --git a/docs/setup.md b/docs/setup.md index 2131866..231ce3d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -25,7 +25,10 @@ The examples use these names. Use your own names instead. Run the commands for the broker host as root, for example in a shell from `sudo -i`, on its console or from a trusted computer ([section 1](#where-you-do-the-setup)). Run the commands for the agent -machine as the user who runs the agent. +machine as the user who runs the agent. Only the install of nodkey is +different: run it as root on each host, also on the agent machines, from +a root shell that does not come through the agent's account +([section 2](#2-get-nodkey)). ## Contents @@ -100,23 +103,101 @@ section 5, item 8). So: - **Keep no way to get root on the broker host on an agent machine**: no SSH key for the broker host, no SSH agent or `ControlMaster` socket that reaches it, no saved password. -- **Copy the binary straight from the machine that built or downloaded - it** to the broker host, not through an agent machine. Then check its - SHA-256 on the broker host ([section 2](#2-get-nodkey)). +- **The user of the agent never downloads, builds or copies the nodkey + binary**, on any machine: the agent could change the file before it is + installed. Install nodkey as root on each host, with the installer. Or + copy it straight from a trusted machine where you built or downloaded + it, not through an agent machine, and check its SHA-256 on the broker + host ([section 2](#2-get-nodkey)). ## 2. Get nodkey Install the same version of nodkey on the broker host and on each agent -machine, as `/usr/local/bin/nodkey`, owned by root. There are two ways -to get it: from a release, or from the source. Use a trusted machine for -both, never an agent machine ([section 1](#where-you-do-the-setup)). +machine, as `/usr/local/bin/nodkey`, owned by root. **Do it as root on +each host, never as the user of the agent.** The agent can change what +its own user downloads, builds or copies +([section 1](#where-you-do-the-setup)). Get the root shell in a way that +does not go through the agent's account: + +- **On the broker host:** on its console, or from a trusted computer, as + for the rest of the setup. Never set it up or update it through an + agent machine. +- **On an agent machine:** on its console, or with SSH as an admin user + from a trusted computer. If you can, do not type `sudo` in the terminal + where the agent works, or in the agent's account. The agent can put its + own `sudo` there (a program of that name earlier in `PATH`, an alias or + a function), or type into that terminal. + +This has a limit. On an agent machine that the agent controls, nodkey +cannot keep its own client safe. But nodkey's promises come from the +broker, which trusts nothing from the client: a changed client binary +gets no more than what you approve on the phone +([THREAT-MODEL.md T20](../THREAT-MODEL.md)). + +There are two ways to install: the installer, or by hand. + +### The installer + +`install.sh` in the repository installs the newest release, or the +version of `--version X.Y.Z`. It needs `curl`, `tar`, `gzip`, +`sha256sum`, `install`, `mktemp`, `stat` and `uname`, and no GitHub +login. Before it installs anything, it checks the SHA-256 and the build +attestation of the release, and that the new nodkey says it is the +version that you want +([DESIGN.md section 18](../DESIGN.md#18-build-ci-and-releases)). If a +check fails, it installs nothing. It restarts nothing. As root, it also +refuses a directory or a file that another user than root can change: +`/usr/local/bin`, the old `nodkey` in it, and the temporary directory +(`TMPDIR`, or `/tmp`). + +The first time, on each host: download the script as root, read it, and +run it as root: -**From a release.** This is the easier way. Download the tarball for -your machine, such as `nodkey_0.1.0_linux_amd64.tar.gz` -(`nodkey_0.1.0_linux_arm64.tar.gz` on 64-bit ARM), and `SHA256SUMS` from -the [release page](https://github.com/code6x/nodkey/releases). Check -both, as [docs/releasing.md](releasing.md#verify-a-download) says. Then -take the file `nodkey` out of the tarball: +```sh +sudo curl --proto '=https' --tlsv1.2 -fsSL -o /root/install.sh https://raw.githubusercontent.com/code6x/nodkey/main/install.sh +sudo less /root/install.sh +sudo sh /root/install.sh +nodkey version +``` + +On the broker host, where no agent runs, the short form works too. But +it runs the script before you can read it: + +```sh +curl --proto '=https' --tlsv1.2 -fsSL https://raw.githubusercontent.com/code6x/nodkey/main/install.sh | sudo sh +``` + +To check the attestation, the installer downloads gh 2.102.0, and +checks it against the SHA-256 in the script. To use a gh that is +installed already, 2.93.0 or newer, add `--gh /usr/bin/gh`. Only root may +be able to change that gh and its directories: root runs it. The +installer needs the Sigstore bundle of the attestation in the release, +`nodkey_.sigstore.json`. 0.1.0 has none: install 0.1.0 by hand. + +When that version is installed already, the installer does nothing. It +does not check the installed file then. To replace it with a checked +copy, add `--reinstall`. + +Later, `nodkey update` runs the copy of the installer that is built into +the installed nodkey ([section 13](#a-new-version-of-nodkey)). It takes +`--version`, `--gh` and `--reinstall` too. + +### By hand + +Use a trusted machine for this, never an agent machine +([section 1](#where-you-do-the-setup)). Get nodkey from a release, or +from the source. + +**From a release.** Download the tarball for your machine, such as +`nodkey_0.1.0_linux_amd64.tar.gz` (`nodkey_0.1.0_linux_arm64.tar.gz` on +64-bit ARM), `SHA256SUMS`, and the bundle `nodkey_.sigstore.json` +if the release has one, from the +[release page](https://github.com/code6x/nodkey/releases). Check it, as +[docs/releasing.md](releasing.md#verify-a-download) says, with all the +steps there. From gh 2.96.0 on, `gh release download` needs no GitHub +login. `gh attestation verify` needs none with `--bundle`, but needs one +without it, such as for 0.1.0. Then take the file `nodkey` out of the +tarball: ```sh tar -xzf nodkey_0.1.0_linux_amd64.tar.gz nodkey @@ -1256,29 +1337,43 @@ cut shows as an anchor whose entry is missing. ### A new version of nodkey 1. Read the release notes. -2. Get the new version, and check it ([section 2](#2-get-nodkey)). -3. Get the example unit of the new version from its tag, as in +2. On the broker host, as root, set `VERSION` to the new version, such + as `0.2.0`, and install it: + + ```sh + VERSION=0.2.0 + nodkey update --version "$VERSION" + ``` + + It runs the installer that is built into the installed nodkey, and + checks the new release in the same way ([section 2](#2-get-nodkey)). + It also takes an older version. nodkey 0.1.0 has no `nodkey update`: + update it with the installer of section 2, with + `--version "$VERSION"`. Or get the new version by hand, check it, and + install it with + `install -o root -g root -m 0755 nodkey /usr/local/bin/nodkey`. +3. Get the example unit of the same version from its tag, as in [section 8](#8-the-systemd-unit), but save it as `nodkey.service`, not over your unit. After a build from the source, use the unit in your copy of the repository instead. Compare it with your unit, and take over the changes: ```sh - VERSION=0.1.0 # the new version curl -fL -o nodkey.service "https://raw.githubusercontent.com/code6x/nodkey/refs/tags/v$VERSION/contrib/systemd/nodkey.service" diff -u /etc/systemd/system/nodkey.service nodkey.service ``` -4. Install it, and restart the broker: +4. Restart the broker: ```sh - install -o root -g root -m 0755 nodkey /usr/local/bin/nodkey systemctl daemon-reload systemctl restart nodkey journalctl -u nodkey -n 30 ``` -5. Install the new version on each agent machine, and restart - `nodkey ssh-agent` there. +5. On each agent machine, in a root shell as in + [section 2](#2-get-nodkey), install the same version, such as with + `nodkey update --version 0.2.0`. Then restart `nodkey ssh-agent` + there. A restart ends all grants. Rarely, right after a restart, Telegram reports a polling conflict, and the broker stops with exit code 76: a @@ -1354,6 +1449,8 @@ error names the file, the section or the key, never a secret value. |---|---| | The broker stopped with exit code 76, and an alert says that another program reads the updates of the bot. | Another program polls with the bot token, or a webhook is set. The token may be stolen. Make a new token ([section 13](#a-new-bot-token)), then start the broker. Right after a restart, see [section 13](#a-new-version-of-nodkey). | | The broker stopped with exit code 77. | Telegram does not take the bot token, such as after `/revoke`. Make the credential with the new token, then start the broker. | +| The installer or `nodkey update` ends with `nothing was installed (exit 1)`. | The last line says which step failed. After a network error, try again later. After a failed SHA-256, attestation or version check, do not install that release in another way: someone may have changed it. Report it ([SECURITY.md](../SECURITY.md)). | +| The installer says that `group or others can write to` a directory or a file, or that a path `is owned by user`. | As root, the installer runs programs from these places, so only root may be able to change them, and each directory above them. Fix the owner or the mode, such as with `chmod go-w /usr/local/bin`, or choose another place with `--bin-dir`, `--gh` or `TMPDIR`. A Debian system that was set up long ago may still have `/usr/local/bin` writable by the group `staff`. | | systemd does not start the broker: `systemctl status nodkey` shows `status=243/CREDENTIALS`. | systemd cannot decrypt a credential, so the broker does not run, and nothing is handed out. Check that each file of a `LoadCredentialEncrypted=` line is there, and was made with that name. After a firmware update or a change of the Secure Boot settings, the TPM2 chip may refuse credentials that are bound to PCR 7 ([section 3](#encrypted-credentials)). If you did not update the firmware or change Secure Boot, find out why first: someone may have changed how the machine starts. Then make each credential again, as in [section 13](#13-upgrades-and-key-changes): a new TLS key (and its pin on each client), the bot token, a new `bws` token, the `bw` master password, and new SSH keys. | | The journal says that the bot may not send to this chat. | You did not tap **Start** in the chat with the bot, or you blocked it. | | `a store could not start` | The broker runs without that store, denies requests for its secrets, and tries again with each request. The error names the store and the step. For bw: check the login ([section 5](#bw-a-separate-bitwarden-account)). | diff --git a/install.go b/install.go new file mode 100644 index 0000000..7ac953d --- /dev/null +++ b/install.go @@ -0,0 +1,15 @@ +// Package nodkey holds the install script of nodkey, install.sh, so that +// nodkey update can run the copy that is built into the binary. The code +// that checks a new release then comes from the build that is installed +// and was checked, not fresh from the network (DESIGN.md section 18). +// +// An embed directive cannot reach a parent directory, so the embed is +// here, next to install.sh, at the root of the module. +package nodkey + +import _ "embed" // for go:embed + +// InstallScript is the text of install.sh, byte for byte. +// +//go:embed install.sh +var InstallScript string diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..bd3cb4c --- /dev/null +++ b/install.sh @@ -0,0 +1,640 @@ +#!/bin/sh +# install.sh installs nodkey from a release on GitHub, or updates it. +# +# Run it as root on the host itself: the broker host, or an agent machine. +# Never run it as the user of the agent: the agent could change what that +# user downloads. docs/setup.md (section 2) shows how to run it, and +# THREAT-MODEL.md (T20) says what it trusts. +# +# sh install.sh [--version X.Y.Z] [--bin-dir DIR] [--gh PATH] [--reinstall] +# +# nodkey update runs the copy of this script that is built into nodkey, +# with --bin-dir set to the directory of its own file. +# +# It installs nothing unless every check passes: +# - only root can change the bin directory, the old file, the temporary +# directory and the gh of --gh (when the script does not run as root, +# the user who runs it may too); +# - the SHA-256 of the tarball is the one in SHA256SUMS of the release; +# - gh attestation verify, with the bundle file of the release, says that +# the release workflow of code6x/nodkey on main built the tarball, on a +# runner that GitHub hosts; +# - the new nodkey says that it is the version asked for. The attestation +# proves which workflow built a file, not its version, so this check +# stops an older build that was published under a newer name. +# +# All the work is in functions, and only the last line starts it. So if a +# download through "curl ... | sh" stops in the middle, nothing runs. +# +# Exit codes: 0 done or nothing to do, 1 failure, 64 wrong usage. + +set -eu + +# The gh that checks the attestation when --gh is not given, and the +# SHA-256 of its tarball for each CPU. The SHA-256s are the lines of +# gh_2.102.0_checksums.txt of that release, and both tarballs passed +# "gh attestation verify --repo cli/cli" on 2026-10-17. Change the version +# and both SHA-256s together, and check them in the same way. +gh_version=2.102.0 +gh_sha256_amd64=bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386 +gh_sha256_arm64=7862c86c72f43df3a2d93ddde6f473285b4e2af61b494849846827e513ef6484 + +# The oldest gh that --gh may name. Older versions have known bugs in +# gh attestation verify (GHSA-fgw4-v983-mgp8, GHSA-8xvp-7hj6-mcj9). Newer +# advisories (GHSA-mm27-mwq9-fr5g, GHSA-wjmr-j3rp-mh2g, GHSA-4mq3-hpgx-9cx8) +# are about --signer-repo, --signer-workflow and --source-ref only, which +# this script does not use. +gh_min_version=2.93.0 + +repo=code6x/nodkey + +# Only the release workflow on main may have built the tarball. This is +# the identity of docs/releasing.md, "Verify a download". +cert_identity=https://github.com/code6x/nodkey/.github/workflows/release.yml@refs/heads/main + +# As root, the script runs only the programs of these directories. +root_path=/usr/sbin:/usr/bin:/sbin:/bin + +# Size limits of the downloads, in bytes. An older curl checks only the +# size that the server announces. +max_small=1048576 # 1 MiB: the answer of the API, SHA256SUMS, the bundle +max_big=67108864 # 64 MiB: the tarballs of nodkey and of gh + +usage() { + cat <&2 + exit 1 +} + +# usage_error prints the usage, and the last line says why the usage is +# wrong. It exits with 64. +usage_error() { + usage >&2 + printf 'nodkey: %s (exit 64)\n' "$*" >&2 + exit 64 +} + +# cleanup removes the temporary directory and a new file that was not +# renamed yet (step 11). It runs at every exit, also after a failure. It +# must return 0: under set -e, a failed trap would change the exit code. +cleanup() { + if [ -n "$tmp_target" ]; then + rm -f "$tmp_target" || : + fi + if [ -n "$tmp" ]; then + rm -rf "$tmp" || : + fi + return 0 +} + +# clean_env removes the variables that change what the programs of the +# script do in a way that is not safe here, sets LC_ALL=C, and sets uid. +# As root, PATH becomes root_path, so that only the programs of the system +# run. The variables: +# - TAR_OPTIONS: options for tar, such as --to-command or +# --checkpoint-action=exec, which run commands as root; +# - GZIP: options for gzip; +# - POSIXLY_CORRECT: changes how the GNU programs read their arguments; +# - CURL_HOME: where curl finds .curlrc (download gives curl -q too); +# - CURL_CA_BUNDLE, SSL_CERT_FILE, SSL_CERT_DIR: the TLS certificates +# that curl and gh trust; +# - SSLKEYLOGFILE: a file that curl writes the TLS keys to; +# - OPENSSL_CONF, OPENSSL_MODULES, OPENSSL_ENGINES: the config of +# OpenSSL, which curl reads at its start, and the places of the +# modules it loads. A config can name any library to load as a +# provider; +# - LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, GCONV_PATH: code that each +# program loads; +# - CDPATH and IFS: how cd finds a directory and how the shell splits +# words. +# The shell that runs the script got them too, so nodkey update starts +# it with a fixed environment (DESIGN.md section 15). +clean_env() { + unset TAR_OPTIONS GZIP POSIXLY_CORRECT CURL_HOME CURL_CA_BUNDLE SSL_CERT_FILE SSL_CERT_DIR \ + SSLKEYLOGFILE OPENSSL_CONF OPENSSL_MODULES OPENSSL_ENGINES \ + LD_PRELOAD LD_LIBRARY_PATH LD_AUDIT GCONV_PATH CDPATH IFS + LC_ALL=C + export LC_ALL + uid=$( + PATH=$root_path + export PATH + id -u + ) || die "cannot run id -u" + case $uid in + '' | *[!0-9]*) die "id -u does not print a user ID" ;; + esac + owners=root + if [ "$uid" = 0 ]; then + PATH=$root_path + export PATH + else + owners="root or user $uid" + fi +} + +# is_number reports whether $1 is a number of at most 9 digits, without a +# leading zero. +is_number() { + case $1 in + 0) return 0 ;; + '' | 0* | *[!0-9]* | ??????????*) return 1 ;; + esac + return 0 +} + +# is_version reports whether $1 is a version X.Y.Z: three numbers, and +# nothing else. +is_version() { + case $1 in + *.*.*.* | *[!0-9.]*) return 1 ;; + *.*.*) ;; + *) return 1 ;; + esac + v_rest=${1#*.} + is_number "${1%%.*}" && is_number "${v_rest%%.*}" && is_number "${v_rest#*.}" +} + +# compare_versions sets cmp to -1, 0 or 1 when the version $1 is older +# than, the same as, or newer than the version $2. Both must pass +# is_version, so each part is a small number. +compare_versions() { + cmp_a=$1 + cmp_b=$2 + cmp=0 + for _ in 1 2 3; do + if [ "${cmp_a%%.*}" -lt "${cmp_b%%.*}" ]; then + cmp=-1 + return 0 + fi + if [ "${cmp_a%%.*}" -gt "${cmp_b%%.*}" ]; then + cmp=1 + return 0 + fi + cmp_a=${cmp_a#*.} + cmp_b=${cmp_b#*.} + done +} + +# real_dir sets real to the path of the directory $1 without links, . or +# .. in it. The script then uses this path, so a link that someone changes +# later does not matter. +real_dir() { + real=$(cd -P -- "$1" && pwd -P) || die "cannot find the directory $1" +} + +# check_entry checks that only $owners can change the path $1: its owner +# must be one of them, and group and others must not be able to write to +# it. With $2 "shared", a directory may let them write when it has the +# sticky bit, as /tmp does: then they cannot rename or remove what is not +# theirs in it. +check_entry() { + ce_stat=$(stat -c '%u %a' "$1") || die "cannot read the owner and the mode of $1" + ce_owner=${ce_stat%% *} + ce_mode=${ce_stat#* } + case $ce_owner$ce_mode in + '' | *[!0-9]*) die "cannot read the owner and the mode of $1" ;; + esac + if [ "$ce_owner" != 0 ] && [ "$ce_owner" != "$uid" ]; then + die "$1 is owned by user $ce_owner; it must be owned by $owners" + fi + if [ "$((0$ce_mode & 022))" != 0 ]; then + if [ "$2" != shared ] || [ ! -d "$1" ] || [ "$((0$ce_mode & 01000))" = 0 ]; then + die "group or others can write to $1; only $owners may" + fi + fi +} + +# check_tree checks the absolute path $1 as check_entry does, with $2, and +# each directory above it, up to /, as shared. $1 must have no link, . or +# .. in it (real_dir). +check_tree() { + ct_path=$1 + check_entry "$ct_path" "$2" + while [ "$ct_path" != / ]; do + ct_path=${ct_path%/*} + if [ -z "$ct_path" ]; then + ct_path=/ + fi + check_entry "$ct_path" shared + done +} + +# parse_args reads the arguments into want_version, bin_dir, gh and +# reinstall. +parse_args() { + while [ "$#" -gt 0 ]; do + case $1 in + --version) + if [ "$#" -lt 2 ]; then + usage_error "--version needs a version, such as 0.2.0" + fi + if ! is_version "$2"; then + usage_error "--version must be X.Y.Z, such as 0.2.0" + fi + want_version=$2 + shift 2 + ;; + --bin-dir) + if [ "$#" -lt 2 ]; then + usage_error "--bin-dir needs a directory" + fi + bin_dir=$2 + shift 2 + ;; + --gh) + if [ "$#" -lt 2 ]; then + usage_error "--gh needs the path of gh" + fi + gh=$2 + shift 2 + ;; + --reinstall) + reinstall=1 + shift + ;; + --help | -h) + usage + exit 0 + ;; + *) + usage_error "unknown argument: $1" + ;; + esac + done + case $bin_dir in + /*) ;; + *) usage_error "--bin-dir must be an absolute path" ;; + esac + case $gh in + '' | /*) ;; + *) usage_error "--gh must be an absolute path" ;; + esac +} + +# check_platform checks that this is Linux on amd64 or arm64 (step 1). +check_platform() { + os=$(uname -s) || die "cannot run uname -s" + if [ "$os" != Linux ]; then + die "nodkey runs on Linux only, and this is $os" + fi + machine=$(uname -m) || die "cannot run uname -m" + case $machine in + x86_64 | amd64) + arch=amd64 + gh_sha256=$gh_sha256_amd64 + ;; + aarch64 | arm64) + arch=arm64 + gh_sha256=$gh_sha256_arm64 + ;; + *) + die "nodkey has releases for amd64 and arm64 only, and this machine is $machine" + ;; + esac +} + +# check_paths checks, before anything is downloaded, the places that the +# script runs programs from and writes to: the bin directory, the old +# file, and the gh of --gh. The script runs the old file and the gh, and +# as root it must not run a file that another user can change. A link as +# the old file or as the gh is refused: name the file itself. +check_paths() { + if [ ! -d "$bin_dir" ]; then + die "$bin_dir is not a directory" + fi + real_dir "$bin_dir" + bin_dir=$real + check_tree "$bin_dir" own + if [ ! -w "$bin_dir" ]; then + die "cannot write to $bin_dir; run this as root" + fi + target=$bin_dir/nodkey + if [ -L "$target" ]; then + die "$target is a link; the script replaces only a file" + fi + if [ -e "$target" ]; then + if [ ! -f "$target" ]; then + die "$target is not a file" + fi + check_entry "$target" own + fi + if [ -n "$gh" ]; then + if [ -L "$gh" ] || [ ! -f "$gh" ] || [ ! -x "$gh" ]; then + die "--gh $gh is not a program file; name the file itself, not a link" + fi + gh_dir=${gh%/*} + real_dir "${gh_dir:-/}" + gh=${real%/}/${gh##*/} + check_tree "$gh" own + fi +} + +# make_tmp makes the private temporary directory in TMPDIR, or /tmp: +# mktemp gives it mode 0700. TMPDIR, and each directory above it, must be +# safe as check_tree says: else another user could swap a checked file in +# it for another one. The trap of main removes the directory. +make_tmp() { + real_dir "${TMPDIR:-/tmp}" + tmp_parent=$real + check_tree "$tmp_parent" shared + tmp=$(mktemp -d "$tmp_parent/nodkey-install.XXXXXXXX") || die "cannot make a temporary directory in $tmp_parent" + mkdir "$tmp/home" "$tmp/gh" "$tmp/new" || die "cannot make directories in $tmp" +} + +# download gets the URL $1 into the file $2, at most $3 bytes: HTTPS only, +# also after a redirect, with TLS 1.2 or newer, with retries and a time +# limit. An HTTP error is a failure. -q comes first, so curl reads no +# .curlrc. +download() { + curl -q --proto '=https' --proto-redir '=https' --tlsv1.2 --fail --silent --show-error --location \ + --retry 3 --connect-timeout 30 --max-time 600 --max-filesize "$3" --output "$2" "$1" /dev/null) || installed_out= + installed=${installed_out#nodkey } + if [ "nodkey $installed" != "$installed_out" ] || ! is_version "$installed"; then + installed= + fi + fi + if [ -z "$installed" ]; then + if [ -e "$target" ]; then + say "$target has an unknown version. nodkey $version will replace it." + else + say "Installing nodkey $version as $target." + fi + return 0 + fi + compare_versions "$version" "$installed" + case $cmp in + 0) + if [ -z "$reinstall" ]; then + say "$target says that it is nodkey $version already. Nothing to do." + say "This did not check the installed file. To replace it with a checked copy, run this again with --reinstall." + exit 0 + fi + say "Installing nodkey $version again as $target, as --reinstall asks." + ;; + 1) + say "Updating $target from nodkey $installed to nodkey $version." + ;; + *) + if [ -z "$want_version" ]; then + say "$target says that it is nodkey $installed, which is newer than $version, the newest release. Nothing to do." + say "This did not check the installed file. To install $version anyway, run this again with --version $version." + exit 0 + fi + say "Replacing nodkey $installed with the older nodkey $version, as --version asks." + ;; + esac +} + +# download_release gets the tarball for this machine, SHA256SUMS and the +# bundle of the build attestation (step 4). +download_release() { + tarball=nodkey_${version}_linux_${arch}.tar.gz + bundle=nodkey_${version}.sigstore.json + base=https://github.com/$repo/releases/download/v$version + say "Downloading $tarball, SHA256SUMS and $bundle." + download "$base/$tarball" "$tmp/$tarball" "$max_big" || die "cannot download $base/$tarball" + download "$base/SHA256SUMS" "$tmp/SHA256SUMS" "$max_small" || die "cannot download $base/SHA256SUMS" + download "$base/$bundle" "$tmp/$bundle" "$max_small" || + die "cannot download $base/$bundle (0.1.0 has no such file; see docs/setup.md section 2)" +} + +# check_sha256 checks the SHA-256 of the tarball against its own line in +# SHA256SUMS (step 5). It finds a broken download early. It proves +# nothing about who built the file: whoever can publish a release can +# publish its SHA256SUMS too. The attestation proves that. +check_sha256() { + want= + while read -r line_sum line_name || [ -n "$line_sum" ]; do + if [ "$line_name" = "$tarball" ] || [ "$line_name" = "*$tarball" ]; then + if [ -n "$want" ]; then + die "SHA256SUMS has more than one line for $tarball" + fi + want=$line_sum + fi + done <"$tmp/SHA256SUMS" + if [ -z "$want" ]; then + die "SHA256SUMS has no line for $tarball" + fi + sha256_of "$tmp/$tarball" + if [ "$file_sha256" != "$want" ]; then + die "the SHA-256 of $tarball is not the one in SHA256SUMS" + fi +} + +# get_gh sets gh to the gh that checks the attestation (step 6): the one +# of --gh, which check_paths checked, or gh $gh_version, downloaded and +# checked against the SHA-256 in this script. It must be $gh_min_version +# or newer: the first line of gh --version must be "gh version X.Y.Z", +# then a space or nothing, so a version such as 2.93.0-rc.1 does not +# count. +get_gh() { + gh_hint= + if [ -z "$gh" ]; then + gh_hint=" (if programs cannot run in $tmp_parent, set TMPDIR to a directory that only root can write, such as /root, or use --gh)" + gh_tarball=gh_${gh_version}_linux_${arch}.tar.gz + say "Downloading gh $gh_version to check the attestation." + download "https://github.com/cli/cli/releases/download/v$gh_version/$gh_tarball" "$tmp/$gh_tarball" "$max_big" || + die "cannot download $gh_tarball" + sha256_of "$tmp/$gh_tarball" + if [ "$file_sha256" != "$gh_sha256" ]; then + die "the SHA-256 of $gh_tarball is not the one in this script" + fi + tar --no-same-owner -xzf "$tmp/$gh_tarball" -C "$tmp/gh" "gh_${gh_version}_linux_${arch}/bin/gh" >"$FAKE_LOGS/curl" +out= +max= +url= +while [ "$#" -gt 0 ]; do + case $1 in + --output) + out=$2 + shift + ;; + --max-filesize) + max=$2 + shift + ;; + esac + url=$1 + shift +done +case $url in +https://*) ;; +*) + echo "fake curl: not HTTPS: $url" >&2 + exit 1 + ;; +esac +file=$FAKE_WEB/${url#https://} +if [ ! -f "$file" ]; then + echo "curl: (22) The requested URL returned error: 404" >&2 + exit 22 +fi +if [ -n "$max" ] && [ "$(wc -c <"$file")" -gt "$max" ]; then + echo "curl: (63) Maximum file size exceeded" >&2 + exit 63 +fi +cp "$file" "$out" +` + +const fakeUname = `#!/bin/sh +# A fake uname: the system and the CPU of the test. +case $1 in +-s) echo "${FAKE_UNAME_S:-Linux}" ;; +-m) echo "${FAKE_UNAME_M:-x86_64}" ;; +*) exit 1 ;; +esac +` + +const fakeGh = `#!/bin/sh +# A fake gh. It writes down each call, one a line, its own path, and the +# environment of each kind of call. For --version, it prints the version +# $FAKE_GH_VERSION, or exits with $FAKE_GH_VERSION_EXIT. For attestation +# verify, it writes down its arguments, keeps a copy of the file and of the +# bundle to check, and exits with $FAKE_GH_EXIT. With $FAKE_GH_WAIT, it +# makes that file instead, and waits. +printf '%s\n' "$*" >>"$FAKE_LOGS/gh" +printf '%s\n' "$0" >"$FAKE_LOGS/gh-path" +env >"$FAKE_LOGS/gh-env-$1" +if [ "$1" = --version ]; then + if [ -n "${FAKE_GH_VERSION_EXIT:-}" ]; then + exit "$FAKE_GH_VERSION_EXIT" + fi + echo "gh version ${FAKE_GH_VERSION:-2.102.0} (2026-09-30)" + echo "https://github.com/cli/cli/releases/tag/v${FAKE_GH_VERSION:-2.102.0}" + exit 0 +fi +printf '%s\n' "$@" >"$FAKE_LOGS/gh-args" +cp "$3" "$FAKE_LOGS/gh-file" || exit 2 +cp "$5" "$FAKE_LOGS/gh-bundle" || exit 2 +if [ -n "${FAKE_GH_WAIT:-}" ]; then + : >"$FAKE_GH_WAIT" + exec sleep 60 +fi +exit "${FAKE_GH_EXIT:-0}" +` + +// fakeNodkey returns a fake nodkey. "nodkey version" prints the lines. +// The note tells copies apart, such as the CPU of a tarball. Each run +// writes the note to $FAKE_LOGS/nodkey-runs, so a test can see which +// copies ran. +func fakeNodkey(note string, lines ...string) []byte { + return fmt.Appendf(nil, "#!/bin/sh\n# A fake nodkey (%s).\necho '%s' >>\"$FAKE_LOGS/nodkey-runs\"\n"+ + "if [ \"$1\" = version ]; then\n\tprintf '%%s\\n' '%s'\nfi\n", + note, note, strings.Join(lines, "' '")) +} + +// failingNodkey is a fake nodkey that does not run. +var failingNodkey = []byte("#!/bin/sh\necho 'cannot run' >&2\nexit 1\n") + +// says returns the fake nodkey of each CPU of a release, for +// installer.release. +func says(lines ...string) func(arch string) []byte { + return func(arch string) []byte { return fakeNodkey(arch, lines...) } +} + +// tarFile is a file in a tarball of tarGz. +type tarFile struct { + name string + mode int64 + data []byte +} + +// tarGz returns a tar.gz file with these files. +func tarGz(t *testing.T, files ...tarFile) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, f := range files { + hdr := &tar.Header{Name: f.name, Mode: f.mode, Size: int64(len(f.data)), ModTime: time.Unix(1_760_000_000, 0)} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(f.data); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// installer runs install.sh against fakes. +type installer struct { + t *testing.T + dir string // the working directory of the script + script string // the path of the script + web string // the files of the fake GitHub + bin string // the directory of --bin-dir + tmp string // TMPDIR of the script: it must be empty after each run + logs string // what the fake programs write down + fakes string // the fake curl and uname, first in PATH + gh string // the fake gh, for --gh + binArg string // the value of --bin-dir, if it is not bin + env []string // more environment for the script +} + +func newInstaller(t *testing.T) *installer { + t.Helper() + if os.Geteuid() == 0 { + t.Skip("as root, install.sh runs only the programs of the system, not the fakes of the test") + } + for _, name := range []string{"sed", "tar", "gzip", "sha256sum", "install", "mktemp", "id", "stat", "cp", "env", "wc", "sleep"} { + if _, err := exec.LookPath(name); err != nil { + skipOrFail(t, "%s is not installed", name) + } + } + script, err := filepath.Abs("install.sh") + if err != nil { + t.Fatal(err) + } + // The script refuses a directory that group or others can write to. + // Under a umask such as 002, t.TempDir is one. + dir := privateDir(t) + in := &installer{ + t: t, + dir: dir, + script: script, + web: filepath.Join(dir, "web"), + bin: filepath.Join(dir, "bin"), + tmp: filepath.Join(dir, "tmp"), + logs: filepath.Join(dir, "logs"), + fakes: filepath.Join(dir, "fakes"), + gh: filepath.Join(dir, "gh"), + } + for _, d := range []string{in.web, in.bin, in.tmp, in.logs, in.fakes} { + mkdir(t, d, 0o700) + } + writeFile(t, filepath.Join(in.fakes, "curl"), []byte(fakeCurl), 0o700) + writeFile(t, filepath.Join(in.fakes, "uname"), []byte(fakeUname), 0o700) + writeFile(t, in.gh, []byte(fakeGh), 0o700) + if exec.CommandContext(t.Context(), filepath.Join(in.fakes, "uname"), "-s").Run() != nil { //nolint:gosec // G204: a program that the test wrote. + skipOrFail(t, "a program in the temporary directory %s cannot run", dir) + } + return in +} + +// privateDir returns a new temporary directory with mode 0700, by its path +// without links: the script prints such paths. +func privateDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(dir, 0o700); err != nil { //nolint:gosec // G302: a directory, private to its owner. + t.Fatal(err) + } + return dir +} + +// mkdir makes the directory path with exactly mode. +func mkdir(t *testing.T, path string, mode os.FileMode) { + t.Helper() + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } +} + +// latest makes the answer of the fake GitHub API for the newest release, +// in the form of the real one. +func (in *installer) latest(tag string) { + in.serve(latestPath, fmt.Appendf(nil, "{\n \"url\": \"https://api.github.com/repos/code6x/nodkey/releases/1\",\n"+ + " \"tag_name\": %q,\n \"name\": %q,\n \"draft\": false\n}\n", tag, tag)) +} + +// release puts a release on the fake GitHub: for each CPU a tarball with +// nodkey, LICENSE and README.md, and SHA256SUMS and the bundle. nodkey +// returns the file nodkey of each CPU. release returns the tarballs. +func (in *installer) release(version string, nodkey func(arch string) []byte) map[string][]byte { + tarballs := map[string][]byte{} + var sums strings.Builder + for _, arch := range []string{"amd64", "arm64"} { + name := "nodkey_" + version + "_linux_" + arch + ".tar.gz" + tarballs[arch] = tarGz(in.t, + tarFile{"nodkey", 0o755, nodkey(arch)}, + tarFile{"LICENSE", 0o644, []byte("a made-up license\n")}, + tarFile{"README.md", 0o644, []byte("# a made-up readme\n")}) + in.serve(releasePath+version+"/"+name, tarballs[arch]) + fmt.Fprintf(&sums, "%x %s\n", sha256.Sum256(tarballs[arch]), name) + } + in.serve(releasePath+version+"/SHA256SUMS", []byte(sums.String())) + in.serve(releasePath+version+"/nodkey_"+version+".sigstore.json", []byte(bundle(version))) + return tarballs +} + +// bundle is the made-up bundle of a release. +func bundle(version string) string { + return `{"mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", "release": "` + version + `"}` + "\n" +} + +// ghRelease puts a tarball of gh 2.102.0 for amd64 on the fake GitHub, +// with the fake gh in it. It returns the SHA-256 of the tarball, in hex. +func (in *installer) ghRelease() string { + data := tarGz(in.t, + tarFile{"gh_2.102.0_linux_amd64/LICENSE", 0o644, []byte("a made-up license\n")}, + tarFile{"gh_2.102.0_linux_amd64/bin/gh", 0o755, []byte(fakeGh)}) + in.serve(ghPath+"2.102.0/gh_2.102.0_linux_amd64.tar.gz", data) + return fmt.Sprintf("%x", sha256.Sum256(data)) +} + +// serve puts a file at https://path on the fake GitHub. +func (in *installer) serve(path string, data []byte) { + in.t.Helper() + file := filepath.Join(in.web, filepath.FromSlash(path)) + if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil { + in.t.Fatal(err) + } + writeFile(in.t, file, data, 0o600) +} + +// unserve removes the file at https://path from the fake GitHub. +func (in *installer) unserve(path string) { + in.t.Helper() + if err := os.Remove(filepath.Join(in.web, filepath.FromSlash(path))); err != nil { + in.t.Fatal(err) + } +} + +// installed makes data the installed nodkey. +func (in *installer) installed(data []byte) { + writeFile(in.t, filepath.Join(in.bin, "nodkey"), data, 0o755) +} + +// result is the end of a run of the script. +type result struct { + code int + stdout, stderr string +} + +// run runs the script with the arguments. +func (in *installer) run(args ...string) result { + in.t.Helper() + return runCmd(in.t, in.command(args...)) +} + +// runCmd runs cmd, and returns its exit code and output. +func runCmd(t *testing.T, cmd *exec.Cmd) result { + t.Helper() + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + var exit *exec.ExitError + if err != nil && !errors.As(err, &exit) { + t.Fatalf("cannot run %s: %v", cmd.Path, err) + } + return result{cmd.ProcessState.ExitCode(), stdout.String(), stderr.String()} +} + +// command returns the command that runs the script with --bin-dir of the +// test and then args, with stdin from /dev/null. A --bin-dir in args +// comes later, so it counts. +func (in *installer) command(args ...string) *exec.Cmd { + bin := in.bin + if in.binArg != "" { + bin = in.binArg + } + cmd := exec.CommandContext(in.t.Context(), "/bin/sh", append([]string{in.script, "--bin-dir", bin}, args...)...) //nolint:gosec // G204: the test runs install.sh with its own arguments. + cmd.Dir = in.dir + cmd.Env = append([]string{ + "PATH=" + in.fakes + string(os.PathListSeparator) + os.Getenv("PATH"), + "HOME=" + in.dir, + "TMPDIR=" + in.tmp, + "FAKE_WEB=" + in.web, + "FAKE_LOGS=" + in.logs, + }, in.env...) + return cmd +} + +// files returns the mode and the content of each file in dir, by name: a +// link has its target, and a directory or another kind of file has only +// its mode. +func files(t *testing.T, dir string) map[string]string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, e := range entries { + info, err := e.Info() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, e.Name()) + got[e.Name()] = info.Mode().String() + switch { + case info.Mode().IsRegular(): + got[e.Name()] += " " + string(readFile(t, path)) + case info.Mode()&fs.ModeSymlink != 0: + target, err := os.Readlink(path) + if err != nil { + t.Fatal(err) + } + got[e.Name()] += " -> " + target + } + } + return got +} + +// checkTmpGone checks that the temporary directory of the script is gone. +func (in *installer) checkTmpGone() { + in.t.Helper() + if left := files(in.t, in.tmp); len(left) != 0 { + in.t.Errorf("the temporary directory is still there: %v", slices.Sorted(maps.Keys(left))) + } +} + +// checkFailed checks a run that must fail with code and a last line that +// holds want, and that it changed nothing in the bin directory. +func (in *installer) checkFailed(r result, code int, want string, before map[string]string) { + in.t.Helper() + end := " (exit " + strconv.Itoa(code) + ")\n" + if code == 1 { + end = "; nothing was installed" + end + } + last := lastLine(r.stderr) + if r.code != code || !strings.HasPrefix(last, "nodkey: ") || !strings.Contains(last, want) || !strings.HasSuffix(last, end) { + in.t.Errorf("exit code %d, last line %q; want %d and a line with %q and %q\nstdout:\n%s\nstderr:\n%s", + r.code, last, code, want, end, r.stdout, r.stderr) + } + if after := files(in.t, in.bin); !maps.Equal(after, before) { + in.t.Errorf("the bin directory changed: %v, before %v", slices.Sorted(maps.Keys(after)), slices.Sorted(maps.Keys(before))) + } + in.checkTmpGone() +} + +// curlCalls returns the arguments of each call of curl. +func (in *installer) curlCalls() [][]string { + in.t.Helper() + var calls [][]string + for _, line := range in.log("curl") { + calls = append(calls, strings.Fields(line)) + } + return calls +} + +// urls returns the URL of each call of curl, in order. +func (in *installer) urls() []string { + in.t.Helper() + var urls []string + for _, call := range in.curlCalls() { + urls = append(urls, call[len(call)-1]) + } + return urls +} + +// log returns the lines that a fake wrote to the log name, or none. +func (in *installer) log(name string) []string { + in.t.Helper() + data, err := os.ReadFile(filepath.Join(in.logs, name)) //nolint:gosec // G304: a file in the test's own directory. + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + in.t.Fatal(err) + } + return strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") +} + +// ghChecked reports whether gh attestation verify ran. +func (in *installer) ghChecked() bool { + return in.log("gh-args") != nil +} + +// releaseURLs are the URLs of the files of a release, for amd64. +func releaseURLs(version string) []string { + base := "https://" + releasePath + version + "/" + return []string{base + "nodkey_" + version + "_linux_amd64.tar.gz", base + "SHA256SUMS", base + "nodkey_" + version + ".sigstore.json"} +} + +const latestURL = "https://" + latestPath + +// TestInstall installs a release where no nodkey is, and checks how the +// script ran curl and gh. +func TestInstall(t *testing.T) { + in := newInstaller(t) + in.latest("v0.2.0") + in.release("0.2.0", says("nodkey 0.2.0")) + // Tokens, a host and other places for the config of gh: none of them + // may reach gh. + token := "made-up-token-" + rand.Text() + for _, name := range tokenVars { + in.env = append(in.env, name+"="+token) + } + in.env = append(in.env, "GH_CONFIG_DIR=/nonexistent/gh", "XDG_CONFIG_HOME=/nonexistent/config", + "XDG_CACHE_HOME=/nonexistent/cache", "XDG_DATA_HOME=/nonexistent/data", "XDG_STATE_HOME=/nonexistent/state") + // Variables that change what the programs do: the script removes + // them. Through TAR_OPTIONS, tar would run a command. + ran := filepath.Join(in.dir, "tar-ran-a-command") + in.env = append(in.env, "TAR_OPTIONS=--checkpoint=1 --checkpoint-action=exec=touch "+ran, "LC_ALL=de_DE.UTF-8") + for _, name := range unsafeVars { + in.env = append(in.env, name+"=/nonexistent/made-up") + } + + r := in.run("--gh", in.gh) + if r.code != 0 { + t.Fatalf("exit code %d\nstdout:\n%s\nstderr:\n%s", r.code, r.stdout, r.stderr) + } + // Only nodkey of amd64 is in the bin directory, with mode 0755. + want := map[string]string{"nodkey": "-rwxr-xr-x " + string(fakeNodkey("amd64", "nodkey 0.2.0"))} + if got := files(t, in.bin); !maps.Equal(got, want) { + t.Errorf("the bin directory holds %q, want %q", got, want) + } + in.checkTmpGone() + for _, s := range []string{"Installing nodkey 0.2.0 as " + in.bin + "/nodkey.", "version says: nodkey 0.2.0", + "systemctl restart nodkey", "A restart ends all grants", "each nodkey ssh-agent"} { + if !strings.Contains(r.stdout, s) { + t.Errorf("stdout does not say %q:\n%s", s, r.stdout) + } + } + if _, err := os.Lstat(ran); err == nil { + t.Error("tar ran the command of TAR_OPTIONS") + } + + // curl: the newest release, then the files of the release, each with + // no .curlrc, over HTTPS only, with TLS 1.2 or newer, failing on an + // HTTP error, with retries, a time limit and a size limit. + wantURLs := append([]string{latestURL}, releaseURLs("0.2.0")...) + if got := in.urls(); !slices.Equal(got, wantURLs) { + t.Errorf("curl got the URLs\n%q\nwant\n%q", got, wantURLs) + } + for i, call := range in.curlCalls() { + if call[0] != "-q" { + t.Errorf("a call of curl does not start with -q, so curl reads .curlrc: %q", call) + } + limit := maxSmall + if strings.HasSuffix(call[len(call)-1], ".tar.gz") { + limit = maxBig + } + for _, want := range [][]string{{"--proto", "=https"}, {"--proto-redir", "=https"}, {"--tlsv1.2"}, {"--fail"}, + {"--retry", "3"}, {"--max-time", "600"}, {"--max-filesize", limit}} { + if !slices.ContainsFunc(windows(call, len(want)), func(w []string) bool { return slices.Equal(w, want) }) { + t.Errorf("call %d of curl has no %q: %q", i, want, call) + } + } + } + + // gh: its version, then the check with the identity of + // docs/releasing.md, of the file that the script downloaded. + if got, want := in.log("gh"), []string{"--version", "attestation verify"}; len(got) != 2 || got[0] != want[0] || !strings.HasPrefix(got[1], want[1]) { + t.Fatalf("calls of gh: %q, want --version and then attestation verify", got) + } + args := in.log("gh-args") + if len(args) < 3 { + t.Fatalf("gh got %q", args) + } + tmp := filepath.Dir(args[2]) // the temporary directory of the script + if filepath.Dir(tmp) != in.tmp || !strings.HasPrefix(filepath.Base(tmp), "nodkey-install.") { + t.Errorf("gh checked %s, not a file in a new directory in TMPDIR %s", args[2], in.tmp) + } + wantArgs := []string{"attestation", "verify", filepath.Join(tmp, "nodkey_0.2.0_linux_amd64.tar.gz"), + "--bundle", filepath.Join(tmp, "nodkey_0.2.0.sigstore.json"), + "--repo", "code6x/nodkey", "--cert-identity", certIdentity, "--deny-self-hosted-runners"} + if !slices.Equal(args, wantArgs) { + t.Errorf("gh got the arguments\n%q\nwant\n%q", args, wantArgs) + } + served := readFile(t, filepath.Join(in.web, releasePath+"0.2.0", "nodkey_0.2.0_linux_amd64.tar.gz")) + if checked := readFile(t, filepath.Join(in.logs, "gh-file")); !bytes.Equal(checked, served) { + t.Error("gh checked another file than the tarball of the release") + } + if checked := readFile(t, filepath.Join(in.logs, "gh-bundle")); string(checked) != bundle("0.2.0") { + t.Errorf("gh checked with the bundle %q, want %q", checked, bundle("0.2.0")) + } + + // The environment of gh, for both calls: no token and no host, its + // home, config and XDG directories in the temporary directory, no + // update notices, prompts or telemetry, LC_ALL=C, and none of the + // unsafe variables. + for _, call := range []string{"--version", "attestation"} { + env := readEnv(t, filepath.Join(in.logs, "gh-env-"+call)) + for _, name := range append(slices.Clone(tokenVars), append([]string{"TAR_OPTIONS"}, unsafeVars...)...) { + if v, ok := env[name]; ok { + t.Errorf("gh %s got %s (%d bytes)", call, name, len(v)) + } + } + for name, value := range env { + if strings.Contains(value, token) { + t.Errorf("gh %s got the token in %s", call, name) + } + } + if env["HOME"] != filepath.Join(tmp, "home") { + t.Errorf("gh %s got HOME=%q, want %q", call, env["HOME"], filepath.Join(tmp, "home")) + } + for _, name := range []string{"GH_CONFIG_DIR", "XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"} { + if !strings.HasPrefix(env[name], tmp+"/") { + t.Errorf("gh %s got %s=%q, want a directory in %s", call, name, env[name], tmp) + } + } + for name, want := range map[string]string{"GH_NO_UPDATE_NOTIFIER": "1", "GH_NO_EXTENSION_UPDATE_NOTIFIER": "1", + "GH_PROMPT_DISABLED": "1", "GH_TELEMETRY": "false", "LC_ALL": "C"} { + if env[name] != want { + t.Errorf("gh %s got %s=%q, want %q", call, name, env[name], want) + } + } + } +} + +// TestInstallReplaces replaces an installed nodkey: an older version, one +// whose version is not known, the same one with --reinstall, and with +// --version an older one. It also installs with paths that the script +// takes as safe. +func TestInstallReplaces(t *testing.T) { + all := append([]string{latestURL}, releaseURLs("0.2.0")...) + for _, tt := range []struct { + name string + installed []byte // the nodkey that is installed before + setup func(in *installer) // more changes before the run + args []string // after --gh + arch string // the CPU of the tarball that must be installed + say string // a part of stdout + urls []string // what curl gets + }{ + {name: "an older version", installed: fakeNodkey("installed", "nodkey 0.1.0"), arch: "amd64", + say: "Updating BIN/nodkey from nodkey 0.1.0 to nodkey 0.2.0.", urls: all}, + {name: "a build from the source", installed: fakeNodkey("installed", "nodkey dev"), arch: "amd64", + say: "BIN/nodkey has an unknown version. nodkey 0.2.0 will replace it.", urls: all}, + {name: "a build of a commit", installed: fakeNodkey("installed", "nodkey 1a2b3c4"), arch: "amd64", + say: "has an unknown version", urls: all}, + {name: "a file that does not run", installed: failingNodkey, arch: "amd64", say: "has an unknown version", urls: all}, + {name: "the same version, with --reinstall", installed: fakeNodkey("installed", "nodkey 0.2.0"), + args: []string{"--reinstall"}, arch: "amd64", + say: "Installing nodkey 0.2.0 again as BIN/nodkey, as --reinstall asks.", urls: all}, + {name: "a newer version, with --version", installed: fakeNodkey("installed", "nodkey 0.3.0"), + args: []string{"--version", "0.2.0"}, arch: "amd64", + say: "Replacing nodkey 0.3.0 with the older nodkey 0.2.0, as --version asks.", urls: releaseURLs("0.2.0")}, + {name: "on arm64", setup: env("FAKE_UNAME_M=aarch64"), arch: "arm64", say: "Installing nodkey 0.2.0", + urls: []string{latestURL, "https://" + releasePath + "0.2.0/nodkey_0.2.0_linux_arm64.tar.gz", + "https://" + releasePath + "0.2.0/SHA256SUMS", "https://" + releasePath + "0.2.0/nodkey_0.2.0.sigstore.json"}}, + {name: "on amd64, when uname -m says amd64", setup: env("FAKE_UNAME_M=amd64"), arch: "amd64", + say: "Installing nodkey 0.2.0", urls: all}, + {name: "with gh 2.93.0, the oldest that counts", setup: env("FAKE_GH_VERSION=2.93.0"), arch: "amd64", + say: "with gh 2.93.0.", urls: all}, + // A directory with the sticky bit, as /tmp, may let others write. + {name: "a TMPDIR with the sticky bit", setup: func(in *installer) { + in.tmp = filepath.Join(in.dir, "shared-tmp") + mkdir(in.t, in.tmp, 0o777|os.ModeSticky) + }, arch: "amd64", say: "Installing nodkey 0.2.0", urls: all}, + {name: "a bin directory in a directory with the sticky bit", setup: func(in *installer) { + mkdir(in.t, filepath.Join(in.dir, "shared"), 0o777|os.ModeSticky) + in.bin = filepath.Join(in.dir, "shared", "bin") + mkdir(in.t, in.bin, 0o755) + }, arch: "amd64", say: "Installing nodkey 0.2.0", urls: all}, + // A link in the path of the bin directory is followed once, and the + // script uses the real path from then on. + {name: "a bin directory behind a link", setup: func(in *installer) { + in.binArg = filepath.Join(in.dir, "bin-link") + if err := os.Symlink(in.bin, in.binArg); err != nil { + in.t.Fatal(err) + } + }, arch: "amd64", say: "Installing nodkey 0.2.0 as BIN/nodkey.", urls: all}, + } { + t.Run(tt.name, func(t *testing.T) { + in := newInstaller(t) + in.latest("v0.2.0") + in.release("0.2.0", says("nodkey 0.2.0")) + if tt.installed != nil { + in.installed(tt.installed) + } + if tt.setup != nil { + tt.setup(in) + } + r := in.run(append([]string{"--gh", in.gh}, tt.args...)...) + if r.code != 0 { + t.Fatalf("exit code %d\nstdout:\n%s\nstderr:\n%s", r.code, r.stdout, r.stderr) + } + want := map[string]string{"nodkey": "-rwxr-xr-x " + string(fakeNodkey(tt.arch, "nodkey 0.2.0"))} + if got := files(t, in.bin); !maps.Equal(got, want) { + t.Errorf("the bin directory holds %q, want %q", got, want) + } + if say := strings.ReplaceAll(tt.say, "BIN", in.bin); !strings.Contains(r.stdout, say) { + t.Errorf("stdout does not say %q:\n%s", say, r.stdout) + } + if got := in.urls(); !slices.Equal(got, tt.urls) { + t.Errorf("curl got the URLs\n%q\nwant\n%q", got, tt.urls) + } + in.checkTmpGone() + }) + } +} + +// TestInstallNothingToDo: the version is installed already, or the +// installed one is newer and --version does not ask for the older one. +// The script exits with 0, downloads no release and changes nothing. It +// says that it did not check the installed file. +func TestInstallNothingToDo(t *testing.T) { + for _, tt := range []struct { + name string + installed string + args []string + say string + urls []string + }{ + {"the newest release", "0.2.0", nil, "BIN/nodkey says that it is nodkey 0.2.0 already. Nothing to do.\n" + + "This did not check the installed file. To replace it with a checked copy, run this again with --reinstall.", + []string{latestURL}}, + {"the version of --version", "0.2.0", []string{"--version", "0.2.0"}, "says that it is nodkey 0.2.0 already", nil}, + {"a newer version", "0.3.0", nil, + "BIN/nodkey says that it is nodkey 0.3.0, which is newer than 0.2.0, the newest release. Nothing to do.\n" + + "This did not check the installed file. To install 0.2.0 anyway, run this again with --version 0.2.0.", + []string{latestURL}}, + {"a newer version in the second number", "0.10.0", nil, "which is newer than 0.2.0", []string{latestURL}}, + {"a newer version in the first number", "1.0.0", nil, "which is newer than 0.2.0", []string{latestURL}}, + {"a newer version, with --reinstall", "0.3.0", []string{"--reinstall"}, "which is newer than 0.2.0", []string{latestURL}}, + } { + t.Run(tt.name, func(t *testing.T) { + in := newInstaller(t) + in.latest("v0.2.0") + in.release("0.2.0", says("nodkey 0.2.0")) + in.installed(fakeNodkey("installed", "nodkey "+tt.installed)) + before := files(t, in.bin) + r := in.run(append([]string{"--gh", in.gh}, tt.args...)...) + if r.code != 0 || r.stderr != "" { + t.Errorf("exit code %d, stderr %q; want 0 and nothing", r.code, r.stderr) + } + if say := strings.ReplaceAll(tt.say, "BIN", in.bin); !strings.Contains(r.stdout, say) { + t.Errorf("stdout does not say %q:\n%s", say, r.stdout) + } + if got := in.urls(); !slices.Equal(got, tt.urls) { + t.Errorf("curl got the URLs %q, want %q", got, tt.urls) + } + if in.ghChecked() { + t.Error("gh checked an attestation") + } + if after := files(t, in.bin); !maps.Equal(after, before) { + t.Errorf("the bin directory changed") + } + in.checkTmpGone() + }) + } +} + +// TestInstallDenies: each failed step stops the script with exit code 1 +// and a last line that says why. Nothing is installed: the old nodkey +// stays, and no new file is left next to it. +func TestInstallDenies(t *testing.T) { + const tarball = "nodkey_0.2.0_linux_amd64.tar.gz" + sums := func(lines ...string) func(in *installer) { + return func(in *installer) { + in.serve(releasePath+"0.2.0/SHA256SUMS", []byte(strings.Join(lines, ""))) + } + } + sumOf := func(in *installer, arch string) string { + data := readFile(in.t, filepath.Join(in.web, releasePath+"0.2.0", "nodkey_0.2.0_linux_"+arch+".tar.gz")) + return fmt.Sprintf("%x", sha256.Sum256(data)) + } + for _, tt := range []struct { + name string + setup func(in *installer) // after the newest release 0.2.0 is on the fake GitHub + args []string // nil: --gh with the fake gh + want string // a part of the last line + checked bool // whether gh attestation verify ran + noCurl bool // whether the script must stop before any download + noRun bool // whether the script must run no nodkey and no gh + }{ + {name: "another system", setup: env("FAKE_UNAME_S=Darwin"), noCurl: true, want: "nodkey runs on Linux only, and this is Darwin"}, + {name: "another CPU", setup: env("FAKE_UNAME_M=riscv64"), noCurl: true, want: "amd64 and arm64 only, and this machine is riscv64"}, + + // The paths that the script runs programs from and writes to. + {name: "no bin directory", args: []string{"--bin-dir", "/nonexistent/bin"}, noCurl: true, noRun: true, + want: "/nonexistent/bin is not a directory"}, + {name: "a bin directory that cannot be written", setup: func(in *installer) { chmod(in.t, in.bin, 0o500) }, + noCurl: true, noRun: true, want: "cannot write to BIN; run this as root"}, + {name: "a bin directory that others can write to", setup: func(in *installer) { chmod(in.t, in.bin, 0o777) }, + noCurl: true, noRun: true, want: "group or others can write to BIN; only root or user"}, + {name: "a bin directory that its group can write to", setup: func(in *installer) { chmod(in.t, in.bin, 0o770) }, + noCurl: true, noRun: true, want: "group or others can write to BIN;"}, + {name: "a bin directory with the sticky bit", setup: func(in *installer) { chmod(in.t, in.bin, 0o777|os.ModeSticky) }, + noCurl: true, noRun: true, want: "group or others can write to BIN;"}, + {name: "a bin directory in a directory that others can write to", setup: func(in *installer) { + open := filepath.Join(in.dir, "open") + mkdir(in.t, open, 0o777) + in.bin = filepath.Join(open, "bin") + mkdir(in.t, in.bin, 0o700) + in.installed(fakeNodkey("installed", "nodkey 0.1.0")) + }, noCurl: true, noRun: true, want: "group or others can write to DIR/open;"}, + {name: "an old file that others can write to", setup: func(in *installer) { chmod(in.t, filepath.Join(in.bin, "nodkey"), 0o777) }, + noCurl: true, noRun: true, want: "group or others can write to BIN/nodkey;"}, + {name: "an old file that is a link", setup: func(in *installer) { + mkdir(in.t, filepath.Join(in.dir, "elsewhere"), 0o700) + writeFile(in.t, filepath.Join(in.dir, "elsewhere", "nodkey"), fakeNodkey("elsewhere", "nodkey 0.1.0"), 0o755) + replace(in.t, filepath.Join(in.bin, "nodkey"), func(path string) error { + return os.Symlink(filepath.Join(in.dir, "elsewhere", "nodkey"), path) + }) + }, noCurl: true, noRun: true, want: "BIN/nodkey is a link; the script replaces only a file"}, + {name: "an old file that is a directory", setup: func(in *installer) { + replace(in.t, filepath.Join(in.bin, "nodkey"), func(path string) error { return os.Mkdir(path, 0o700) }) + }, noCurl: true, noRun: true, want: "BIN/nodkey is not a file"}, + {name: "an old file that is a pipe", setup: func(in *installer) { + replace(in.t, filepath.Join(in.bin, "nodkey"), func(path string) error { return syscall.Mkfifo(path, 0o600) }) + }, noCurl: true, noRun: true, want: "BIN/nodkey is not a file"}, + {name: "a TMPDIR that others can write to", setup: func(in *installer) { + in.tmp = filepath.Join(in.dir, "open-tmp") + mkdir(in.t, in.tmp, 0o777) + }, noCurl: true, noRun: true, want: "group or others can write to DIR/open-tmp;"}, + {name: "a TMPDIR in a directory that others can write to", setup: func(in *installer) { + mkdir(in.t, filepath.Join(in.dir, "open"), 0o777) + in.tmp = filepath.Join(in.dir, "open", "tmp") + mkdir(in.t, in.tmp, 0o700) + }, noCurl: true, noRun: true, want: "group or others can write to DIR/open;"}, + {name: "no TMPDIR", setup: env("TMPDIR=/nonexistent/tmp"), noCurl: true, noRun: true, + want: "cannot find the directory /nonexistent/tmp"}, + {name: "a gh that others can write to", setup: func(in *installer) { chmod(in.t, in.gh, 0o777) }, + noCurl: true, noRun: true, want: "group or others can write to DIR/gh;"}, + {name: "a gh in a directory that others can write to", setup: func(in *installer) { + mkdir(in.t, filepath.Join(in.dir, "open"), 0o777) + in.gh = filepath.Join(in.dir, "open", "gh") + writeFile(in.t, in.gh, []byte(fakeGh), 0o700) + }, noCurl: true, noRun: true, want: "group or others can write to DIR/open;"}, + {name: "a gh that is a link", setup: func(in *installer) { + link := filepath.Join(in.dir, "gh-link") + if err := os.Symlink(in.gh, link); err != nil { + in.t.Fatal(err) + } + in.gh = link + }, noCurl: true, noRun: true, want: "--gh DIR/gh-link is not a program file; name the file itself, not a link"}, + {name: "a gh that is not there", args: []string{"--gh", "/nonexistent/gh"}, noCurl: true, noRun: true, + want: "--gh /nonexistent/gh is not a program file"}, + + // The newest release, and the files of the release. + {name: "no answer from the API", setup: func(in *installer) { in.unserve(latestPath) }, want: "cannot ask GitHub for the newest release"}, + {name: "an answer of the API that is too big", setup: func(in *installer) { + in.serve(latestPath, bytes.Repeat([]byte(" "), 1<<20+1)) + }, want: "cannot ask GitHub for the newest release"}, + {name: "a tag without v", setup: latest("0.2.0"), want: "GitHub does not name the newest release in the form vX.Y.Z"}, + {name: "a tag with two numbers", setup: latest("v0.2"), want: "in the form vX.Y.Z"}, + {name: "a tag with four numbers", setup: latest("v0.2.0.1"), want: "in the form vX.Y.Z"}, + {name: "a tag with a leading zero", setup: latest("v0.02.0"), want: "in the form vX.Y.Z"}, + {name: "a tag of a pre-release", setup: latest("v0.2.0-rc.1"), want: "in the form vX.Y.Z"}, + {name: "a tag with a long number", setup: latest("v0.2.1234567890"), want: "in the form vX.Y.Z"}, + {name: "a tag with a space", setup: latest("v0.2.0 "), want: "in the form vX.Y.Z"}, + {name: "two tags", setup: func(in *installer) { + in.serve(latestPath, []byte("{\n \"tag_name\": \"v0.2.0\",\n \"tag_name\": \"v0.3.0\"\n}\n")) + }, want: "in the form vX.Y.Z"}, + {name: "no tag", setup: func(in *installer) { + in.serve(latestPath, []byte("{\n \"name\": \"v0.2.0\"\n}\n")) + }, want: "in the form vX.Y.Z"}, + {name: "no tarball", setup: func(in *installer) { in.unserve(releasePath + "0.2.0/" + tarball) }, + want: "cannot download https://" + releasePath + "0.2.0/" + tarball}, + {name: "no SHA256SUMS", setup: func(in *installer) { in.unserve(releasePath + "0.2.0/SHA256SUMS") }, + want: "cannot download https://" + releasePath + "0.2.0/SHA256SUMS"}, + {name: "a SHA256SUMS that is too big", setup: func(in *installer) { + sums(sumOf(in, "amd64")+" "+tarball+"\n", strings.Repeat("#", 1<<20))(in) + }, want: "cannot download https://" + releasePath + "0.2.0/SHA256SUMS"}, + {name: "no bundle", setup: func(in *installer) { in.unserve(releasePath + "0.2.0/nodkey_0.2.0.sigstore.json") }, + want: "cannot download https://" + releasePath + "0.2.0/nodkey_0.2.0.sigstore.json (0.1.0 has no such file"}, + {name: "a bad checksum", setup: func(in *installer) { + sums(sumOf(in, "arm64")+" "+tarball+"\n", sumOf(in, "arm64")+" nodkey_0.2.0_linux_arm64.tar.gz\n")(in) + }, want: "the SHA-256 of " + tarball + " is not the one in SHA256SUMS"}, + {name: "no line in SHA256SUMS", setup: func(in *installer) { + sums(sumOf(in, "arm64") + " nodkey_0.2.0_linux_arm64.tar.gz\n")(in) + }, want: "SHA256SUMS has no line for " + tarball}, + {name: "a line for a longer name", setup: func(in *installer) { + sums(sumOf(in, "amd64") + " " + tarball + ".old\n")(in) + }, want: "SHA256SUMS has no line for " + tarball}, + {name: "two lines in SHA256SUMS", setup: func(in *installer) { + sums(sumOf(in, "amd64")+" "+tarball+"\n", sumOf(in, "amd64")+" *"+tarball+"\n")(in) + }, want: "SHA256SUMS has more than one line for " + tarball}, + + // gh and the attestation. + {name: "a failed check of the attestation", setup: env("FAKE_GH_EXIT=1"), checked: true, + want: "gh attestation verify failed, so it is not proven that the release workflow built " + tarball}, + {name: "a gh that is too old", setup: env("FAKE_GH_VERSION=2.92.0"), want: " is gh 2.92.0; it must be gh 2.93.0 or newer"}, + {name: "a gh of a pre-release", setup: env("FAKE_GH_VERSION=2.93.0-rc.1"), want: "cannot tell the version of "}, + {name: "a gh of a version with more", setup: env("FAKE_GH_VERSION=2.102.0+ds1"), want: "cannot tell the version of "}, + {name: "a gh of no version", setup: env("FAKE_GH_VERSION=DEV"), want: "cannot tell the version of "}, + {name: "a gh whose --version fails", setup: env("FAKE_GH_VERSION_EXIT=1"), want: "/gh --version; nothing"}, + {name: "a downloaded gh with another SHA-256", setup: func(in *installer) { in.ghRelease() }, args: []string{}, + want: "the SHA-256 of gh_2.102.0_linux_amd64.tar.gz is not the one in this script"}, + {name: "no gh to download", args: []string{}, want: "cannot download gh_2.102.0_linux_amd64.tar.gz"}, + + // The new nodkey. The attestation proves which workflow built a + // file, not which version it is. A release 0.3.0 with an attested + // build of 0.2.0 passes the checksum and the attestation, and must + // still fail. + {name: "an attested older build under a newer name", setup: func(in *installer) { + in.latest("v0.3.0") + in.release("0.3.0", says("nodkey 0.2.0")) + }, checked: true, want: "the new nodkey does not say that it is nodkey 0.3.0"}, + {name: "a new nodkey of another version", setup: rel(says("nodkey 0.1.0")), checked: true, + want: "the new nodkey does not say that it is nodkey 0.2.0"}, + {name: "a new nodkey with more output", setup: rel(says("nodkey 0.2.0", "nodkey 0.3.0")), checked: true, + want: "the new nodkey does not say that it is nodkey 0.2.0"}, + {name: "a new nodkey with a v", setup: rel(says("nodkey v0.2.0")), checked: true, + want: "the new nodkey does not say that it is nodkey 0.2.0"}, + {name: "a new nodkey that does not run", setup: rel(func(string) []byte { return failingNodkey }), checked: true, + want: "the new nodkey does not run"}, + {name: "a tarball without nodkey", setup: func(in *installer) { + data := tarGz(in.t, tarFile{"LICENSE", 0o644, []byte("a made-up license\n")}) + in.serve(releasePath+"0.2.0/"+tarball, data) + sums(fmt.Sprintf("%x %s\n", sha256.Sum256(data), tarball))(in) + }, checked: true, want: "cannot take nodkey out of " + tarball}, + } { + t.Run(tt.name, func(t *testing.T) { + in := newInstaller(t) + in.latest("v0.2.0") + in.release("0.2.0", says("nodkey 0.2.0")) + in.installed(fakeNodkey("installed", "nodkey 0.1.0")) + if tt.setup != nil { + tt.setup(in) + } + args := tt.args + if args == nil { + args = []string{"--gh", in.gh} + } + before := files(t, in.bin) + r := in.run(args...) + want := strings.NewReplacer("BIN", in.bin, "DIR", in.dir).Replace(tt.want) + in.checkFailed(r, 1, want, before) + if in.ghChecked() != tt.checked { + t.Errorf("gh attestation verify ran: %v, want %v", in.ghChecked(), tt.checked) + } + if urls := in.urls(); tt.noCurl && len(urls) != 0 { + t.Errorf("curl got %q, want no call", urls) + } + if runs := append(in.log("nodkey-runs"), in.log("gh")...); tt.noRun && len(runs) != 0 { + t.Errorf("the script ran a nodkey or a gh: %q", runs) + } + // The new nodkey runs only after the attestation passed. + if ranNew := slices.ContainsFunc(in.log("nodkey-runs"), func(s string) bool { return s == "amd64" }); ranNew && !tt.checked { + t.Error("the new nodkey ran, but the attestation was not checked") + } + }) + } +} + +// TestInstallRunsNoNewFileBeforeTheCheck: when the attestation fails, the +// new nodkey never runs, not even for its version. +func TestInstallRunsNoNewFileBeforeTheCheck(t *testing.T) { + in := newInstaller(t) + in.latest("v0.2.0") + in.release("0.2.0", says("nodkey 0.2.0")) + in.installed(fakeNodkey("installed", "nodkey 0.1.0")) + in.env = append(in.env, "FAKE_GH_EXIT=1") + before := files(t, in.bin) + r := in.run("--gh", in.gh) + in.checkFailed(r, 1, "gh attestation verify failed", before) + // Only the installed nodkey ran, for its version. + if runs := in.log("nodkey-runs"); !slices.Equal(runs, []string{"installed"}) { + t.Errorf("these copies of nodkey ran: %q, want only the installed one", runs) + } +} + +// env returns a setup that adds the variables to the environment of the +// script. +func env(vars ...string) func(in *installer) { + return func(in *installer) { in.env = append(in.env, vars...) } +} + +// latest returns a setup that makes tag the newest release. +func latest(tag string) func(in *installer) { + return func(in *installer) { in.latest(tag) } +} + +// rel returns a setup that makes the release 0.2.0 again, with these +// files nodkey. +func rel(nodkey func(arch string) []byte) func(in *installer) { + return func(in *installer) { in.release("0.2.0", nodkey) } +} + +// chmod sets the mode of path, and sets it back to 0700 at the end of the +// test, so that the temporary directory can be removed. +func chmod(t *testing.T, path string, mode os.FileMode) { + t.Helper() + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(path, 0o700) }) //nolint:gosec // G302: back to private, so that the test can remove it. +} + +// replace removes the file path and makes something else there with +// create. +func replace(t *testing.T, path string, create func(path string) error) { + t.Helper() + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := create(path); err != nil { + t.Fatal(err) + } +} + +// TestInstallUsage: wrong arguments stop the script with exit code 64, +// before it downloads anything. +func TestInstallUsage(t *testing.T) { + for _, tt := range []struct { + args []string + want string + }{ + {[]string{"--bogus"}, "unknown argument: --bogus"}, + {[]string{"0.2.0"}, "unknown argument: 0.2.0"}, + {[]string{"--version=0.2.0"}, "unknown argument: --version=0.2.0"}, + {[]string{"--reinstall=yes"}, "unknown argument: --reinstall=yes"}, + {[]string{"--version"}, "--version needs a version, such as 0.2.0"}, + {[]string{"--version", "0.2"}, "--version must be X.Y.Z, such as 0.2.0"}, + {[]string{"--version", "v0.2.0"}, "--version must be X.Y.Z"}, + {[]string{"--version", "0.2.0.1"}, "--version must be X.Y.Z"}, + {[]string{"--version", "00.2.0"}, "--version must be X.Y.Z"}, + {[]string{"--version", "0.-2.0"}, "--version must be X.Y.Z"}, + {[]string{"--version", ""}, "--version must be X.Y.Z"}, + {[]string{"--version", "--bin-dir"}, "--version must be X.Y.Z"}, + {[]string{"--bin-dir"}, "--bin-dir needs a directory"}, + {[]string{"--bin-dir", "bin"}, "--bin-dir must be an absolute path"}, + {[]string{"--gh"}, "--gh needs the path of gh"}, + {[]string{"--gh", "gh"}, "--gh must be an absolute path"}, + } { + t.Run(strings.Join(tt.args, " "), func(t *testing.T) { + in := newInstaller(t) + in.latest("v0.2.0") + in.release("0.2.0", says("nodkey 0.2.0")) + in.installed(fakeNodkey("installed", "nodkey 0.1.0")) + before := files(t, in.bin) + r := in.run(tt.args...) + in.checkFailed(r, 64, tt.want, before) + if !strings.HasPrefix(r.stderr, "Usage: sh install.sh ") || r.stdout != "" { + t.Errorf("stdout %q, stderr %q; want the usage on stderr", r.stdout, r.stderr) + } + if urls := in.urls(); len(urls) != 0 { + t.Errorf("curl got %q", urls) + } + }) + } + for _, help := range []string{"--help", "-h"} { + in := newInstaller(t) + r := in.run(help) + if r.code != 0 || !strings.HasPrefix(r.stdout, "Usage: sh install.sh ") || !strings.Contains(r.stdout, "--reinstall") || r.stderr != "" { + t.Errorf("%s: exit code %d, stdout %q, stderr %q; want 0 and the usage on stdout", help, r.code, r.stdout, r.stderr) + } + } +} + +// TestInstallDownloadsGh runs a copy of the script whose SHA-256 of gh is +// the one of a fake gh: the script downloads the tarball of gh, checks it, +// takes gh out of it and checks the attestation with it. +func TestInstallDownloadsGh(t *testing.T) { + in := newInstaller(t) + in.latest("v0.2.0") + in.release("0.2.0", says("nodkey 0.2.0")) + sum := in.ghRelease() + script := string(readFile(t, in.script)) + pin := regexp.MustCompile(`(?m)^gh_sha256_amd64=[0-9a-f]{64}$`) + if n := len(pin.FindAllString(script, -1)); n != 1 { + t.Fatalf("install.sh has %d lines gh_sha256_amd64=, want 1", n) + } + in.script = filepath.Join(in.dir, "install-with-fake-gh.sh") + writeFile(t, in.script, []byte(pin.ReplaceAllString(script, "gh_sha256_amd64="+sum)), 0o600) + + r := in.run() + if r.code != 0 { + t.Fatalf("exit code %d\nstdout:\n%s\nstderr:\n%s", r.code, r.stdout, r.stderr) + } + want := append([]string{latestURL}, releaseURLs("0.2.0")...) + want = append(want, "https://"+ghPath+"2.102.0/gh_2.102.0_linux_amd64.tar.gz") + if got := in.urls(); !slices.Equal(got, want) { + t.Errorf("curl got the URLs\n%q\nwant\n%q", got, want) + } + if last := in.curlCalls()[len(want)-1]; !slices.Contains(last, maxBig) { + t.Errorf("the download of gh has no size limit %s: %q", maxBig, last) + } + // The gh that ran is the one from the tarball, in the temporary + // directory. + args := in.log("gh-args") + if !in.ghChecked() || len(args) < 3 { + t.Fatalf("gh attestation verify did not run: %q", args) + } + tmp := filepath.Dir(args[2]) + if got, want := in.log("gh-path"), filepath.Join(tmp, "gh", "gh_2.102.0_linux_amd64", "bin", "gh"); len(got) != 1 || got[0] != want { + t.Errorf("the gh that ran is %q, want %s", got, want) + } + if got := files(t, in.bin); got["nodkey"] != "-rwxr-xr-x "+string(fakeNodkey("amd64", "nodkey 0.2.0")) { + t.Errorf("the bin directory holds %q", got) + } + in.checkTmpGone() +} + +// TestInstallStopsOnSIGTERM sends SIGTERM to the script and its programs, +// as Ctrl-C does with SIGINT, while gh checks the attestation. The script +// exits with 1, installs nothing, and removes its temporary directory. +func TestInstallStopsOnSIGTERM(t *testing.T) { + in := newInstaller(t) + in.latest("v0.2.0") + in.release("0.2.0", says("nodkey 0.2.0")) + in.installed(fakeNodkey("installed", "nodkey 0.1.0")) + before := files(t, in.bin) + marker := filepath.Join(in.dir, "gh-waits") + in.env = append(in.env, "FAKE_GH_WAIT="+marker) + cmd := in.command("--gh", in.gh) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + deadline := time.Now().Add(20 * time.Second) + for { + if _, err := os.Stat(marker); err == nil { + break + } + select { + case err := <-done: + t.Fatalf("the script ended before gh waited: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String()) + case <-time.After(10 * time.Millisecond): + } + if time.Now().After(deadline) { + t.Fatal("gh did not start to wait") + } + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(20 * time.Second): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + t.Fatal("the script did not stop on SIGTERM") + } + if code := cmd.ProcessState.ExitCode(); code != 1 { + t.Errorf("exit code %d, want 1\nstderr:\n%s", code, stderr.String()) + } + if after := files(t, in.bin); !maps.Equal(after, before) { + t.Errorf("the bin directory changed") + } + in.checkTmpGone() +} + +// functions writes install.sh without its last line, which starts main, +// into dir, so that a test can load its functions with ".". It returns the +// path of the copy. +func functions(t *testing.T, dir string) string { + t.Helper() + body, ok := strings.CutSuffix(string(readFile(t, "install.sh")), "\nmain \"$@\"\n") + if !ok { + t.Fatal(`install.sh does not end with the line main "$@"`) + } + path := filepath.Join(dir, "functions.sh") + writeFile(t, path, []byte(body+"\n"), 0o600) + return path +} + +// runFunctions runs code with /bin/sh, after it loaded the functions of +// install.sh, with the environment env. +func runFunctions(t *testing.T, lib string, env []string, code string) result { + t.Helper() + cmd := exec.CommandContext(t.Context(), "/bin/sh", "-c", ". \"$0\"\n"+code, lib) //nolint:gosec // G204: the test runs the functions of install.sh. + cmd.Env = env + return runCmd(t, cmd) +} + +// TestInstallChecksAsRoot runs the checks of the paths as the script runs +// them as root, with uid 0: then only root may own a path. A test cannot +// make files of root, so it checks that its own files are refused, and +// that the directories of the system pass. +func TestInstallChecksAsRoot(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("the test needs files that root does not own") + } + dir := privateDir(t) + lib := functions(t, dir) + own := filepath.Join(dir, "own") + writeFile(t, own, []byte("#!/bin/sh\n"), 0o755) + env := []string{"PATH=" + os.Getenv("PATH")} + asRoot := "uid=0\nowners=root\n" + for _, tt := range []struct { + code string + want string // the last line, or "" for exit code 0 + }{ + {"check_tree " + own + " own", "nodkey: " + own + " is owned by user " + strconv.Itoa(os.Geteuid()) + + "; it must be owned by root; nothing was installed (exit 1)\n"}, + {"check_tree " + dir + " shared", "it must be owned by root;"}, + {"check_entry /tmp own", "nodkey: group or others can write to /tmp; only root may; nothing was installed (exit 1)\n"}, + } { + r := runFunctions(t, lib, env, asRoot+tt.code) + if r.code != 1 || !strings.Contains(lastLine(r.stderr), tt.want) { + t.Errorf("%s: exit code %d, stderr %q; want 1 and %q", tt.code, r.code, r.stderr, tt.want) + } + } + // The directories of the system: root owns them, and only /tmp, with + // the sticky bit, lets others write. + for _, code := range []string{"check_tree /usr/bin own", "check_tree /tmp shared", "check_tree / own"} { + path := strings.Fields(code)[1] + if info, err := os.Lstat(path); err != nil || !info.IsDir() || info.Sys().(*syscall.Stat_t).Uid != 0 { + skipOrFail(t, "%s is not a directory of root", path) + } + if r := runFunctions(t, lib, env, asRoot+code); r.code != 0 { + t.Errorf("%s: exit code %d, stderr %q; want 0", code, r.code, r.stderr) + } + } +} + +// TestInstallCleansTheEnvironment runs clean_env of the script. It removes +// the variables that change what the programs of the script do, and sets +// LC_ALL=C. As root (a fake id says 0), it also sets the fixed PATH, which +// here is a directory of the test. +func TestInstallCleansTheEnvironment(t *testing.T) { + dir := privateDir(t) + lib := functions(t, dir) + all := append([]string{"TAR_OPTIONS", "LD_PRELOAD", "LD_AUDIT"}, unsafeVars...) + env := []string{"PATH=" + os.Getenv("PATH"), "LC_ALL=de_DE.UTF-8", "IFS=x"} + for _, name := range all { + env = append(env, name+"=/nonexistent/made-up") + } + show := `printf '%s|%s|' "$uid" "$owners"; printf '%s|' "$LC_ALL" "${IFS-unset}"` + for _, name := range all { + show += `; printf '%s|' "${` + name + `-unset}"` + } + wantVars := "C|unset|" + strings.Repeat("unset|", len(all)) + + // As this user: PATH stays. + r := runFunctions(t, lib, env, "clean_env\n"+show+`; printf '%s' "$PATH"`) + uid := strconv.Itoa(os.Geteuid()) + if want := uid + "|root or user " + uid + "|" + wantVars + os.Getenv("PATH"); r.code != 0 || r.stdout != want { + t.Errorf("as user %s: exit code %d, stdout %q, stderr %q; want %q", uid, r.code, r.stdout, r.stderr, want) + } + + // As root: PATH is root_path. + fakeRoot := filepath.Join(dir, "root-bin") + mkdir(t, fakeRoot, 0o700) + writeFile(t, filepath.Join(fakeRoot, "id"), []byte("#!/bin/sh\necho 0\n"), 0o700) + r = runFunctions(t, lib, env, "root_path="+fakeRoot+"\nclean_env\n"+show+`; printf '%s' "$PATH"`) + if want := "0|root|" + wantVars + fakeRoot; r.code != 0 || r.stdout != want { + t.Errorf("as root: exit code %d, stdout %q, stderr %q; want %q", r.code, r.stdout, r.stderr, want) + } + // The real script uses the directories of the system. + if !strings.Contains(string(readFile(t, "install.sh")), "\nroot_path=/usr/sbin:/usr/bin:/sbin:/bin\n") { + t.Error("install.sh does not set root_path=/usr/sbin:/usr/bin:/sbin:/bin") + } +} + +// TestPinnedGh: the gh that the script downloads is 2.93.0 or newer, and +// the script has a SHA-256 for each CPU. +func TestPinnedGh(t *testing.T) { + script := string(readFile(t, "install.sh")) + value := func(name, pattern string) string { + m := regexp.MustCompile(`(?m)^`+name+`=(`+pattern+`)$`).FindAllStringSubmatch(script, -1) + if len(m) != 1 { + t.Fatalf("install.sh has %d lines %s=%s, want 1", len(m), name, pattern) + } + return m[0][1] + } + num := `(0|[1-9][0-9]*)` + version := value("gh_version", num+`\.`+num+`\.`+num) + if minVersion := value("gh_min_version", num+`\.`+num+`\.`+num); minVersion != "2.93.0" { + t.Errorf("gh_min_version is %s, want 2.93.0", minVersion) + } + var parts [3]int + for i, s := range strings.Split(version, ".") { + parts[i], _ = strconv.Atoi(s) + } + if parts[0] < 2 || (parts[0] == 2 && parts[1] < 93) { + t.Errorf("the pinned gh is %s, want 2.93.0 or newer", version) + } + if value("gh_sha256_amd64", "[0-9a-f]{64}") == value("gh_sha256_arm64", "[0-9a-f]{64}") { + t.Error("gh has the same SHA-256 for amd64 and arm64") + } +} + +// skipOrFail skips the test, or fails it when the CI environment variable +// is set: CI has every program that the tests need. +func skipOrFail(t *testing.T, format string, args ...any) { + t.Helper() + if os.Getenv("CI") != "" { + t.Fatalf("CI is set, so this test must run, but: "+format, args...) + } + t.Skipf(format, args...) +} + +// writeFile writes data to path with mode. It holds syscall.ForkLock while +// the file is open, so that no child process that starts at that time +// gets a copy of the descriptor: then running the file would fail with +// "text file busy" (go.dev/issue/22315). +func writeFile(t *testing.T, path string, data []byte, mode os.FileMode) { + t.Helper() + syscall.ForkLock.RLock() + err := os.WriteFile(path, data, 0o600) + syscall.ForkLock.RUnlock() + if err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } +} + +func readFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) //nolint:gosec // G304: a file of the test. + if err != nil { + t.Fatal(err) + } + return data +} + +// readEnv reads the output of env into a map. +func readEnv(t *testing.T, path string) map[string]string { + t.Helper() + env := map[string]string{} + for line := range strings.Lines(string(readFile(t, path))) { + if name, value, ok := strings.Cut(strings.TrimSuffix(line, "\n"), "="); ok { + env[name] = value + } + } + return env +} + +// lastLine returns the last line of s, with its newline. +func lastLine(s string) string { + body := strings.TrimSuffix(s, "\n") + return s[strings.LastIndex(body, "\n")+1:] +} + +// windows returns each run of n elements of s. +func windows(s []string, n int) [][]string { + var w [][]string + for i := 0; i+n <= len(s); i++ { + w = append(w, s[i:i+n]) + } + return w +}