Skip to content

feat: install script and nodkey update - #95

Merged
groupsky merged 1 commit into
mainfrom
feat/install-update
Oct 17, 2026
Merged

groupsky merged 1 commit into
mainfrom
feat/install-update

Conversation

@groupsky

Copy link
Copy Markdown
Contributor

What changed

install.sh (new, at the root of the repository). It installs the
newest release, or --version X.Y.Z, as root on the host itself: the
broker host or an agent machine. It is POSIX sh and runs in dash. Options:
--version X.Y.Z, --bin-dir DIR (default /usr/local/bin), --gh PATH,
--reinstall, --help. Exit codes: 0 done or nothing to do, 1 failure,
64 wrong usage.

First it makes its environment safe. It removes 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 and IFS, and sets LC_ALL=C. As
root it sets PATH=/usr/sbin:/usr/bin:/sbin:/bin. A comment in the script
says why each variable goes. Then:

  1. Linux only, amd64 or arm64.
  2. Before any download, it checks the places that it runs programs from
    and writes to: the bin directory, the old nodkey, the gh of --gh,
    and TMPDIR (or /tmp). Each of them, and each directory above it up
    to /, must be owned by root and not writable by group or others. A
    directory above them, and TMPDIR itself, may be writable by others
    only with the sticky bit, as /tmp. Links in these paths are resolved
    once (cd -P, pwd -P), and the real path is used from then on. The
    old file and the gh must be regular files, not links. When the script
    does not run as root, the user who runs it may own these paths too
    (this is how the tests run it).
  3. The version of --version, or tag_name of
    https://api.github.com/repos/code6x/nodkey/releases/latest, read with
    sed. Only v and three numbers without leading zeros count.
  4. Same version installed: says so, and that the installed file was not
    checked, exit 0, unless --reinstall. An older version than the
    installed one only with --version. A build from the source (dev,
    or not X.Y.Z) is "unknown version" and is replaced.
  5. Downloads the tarball, SHA256SUMS and nodkey_<version>.sigstore.json
    (ci: publish the attestation bundle with each release #87) into a new mktemp -d directory. curl gets -q (no .curlrc),
    --proto '=https' --proto-redir '=https' --tlsv1.2 --fail, 3 retries,
    30 s to connect, 600 s for each download, and --max-filesize: 64 MiB
    for the tarballs (nodkey and gh), 1 MiB for the API answer,
    SHA256SUMS and the bundle.
  6. Checks the SHA-256 against the tarball's own line in SHA256SUMS.
  7. Downloads gh 2.102.0 and checks it against a SHA-256 in the script, one
    for each CPU. Or --gh PATH. Either must be 2.93.0 or newer; the first
    line of gh --version must be exactly gh version X.Y.Z up to a
    space, so 2.93.0-rc.1 or 2.102.0+ds1 do not count. A comment notes
    that the newer advisories GHSA-mm27-mwq9-fr5g, GHSA-wjmr-j3rp-mh2g and
    GHSA-4mq3-hpgx-9cx8 are about --signer-repo, --signer-workflow and
    --source-ref, which the script does not use.
  8. gh attestation verify <tarball> --bundle <bundle> --repo code6x/nodkey --cert-identity https://github.com/code6x/nodkey/.github/workflows/release.yml@refs/heads/main --deny-self-hosted-runners, with no token or host from the
    environment, HOME, GH_CONFIG_DIR and the XDG_* directories in the
    temporary directory, and no update notices, prompts or telemetry.
  9. Takes only nodkey out of the tarball, copies it into the bin directory
    under a temporary name, and checks that its nodkey version prints
    exactly nodkey <version>. This stops an attested older build under a
    newer name: the attestation proves the workflow, not the version (found
    in the review of ci: publish the attestation bundle with each release #87). The new nodkey runs only after the attestation
    passed.
  10. mv -f over the old file: a rename in one directory.
  11. Prints what the installed file says, and what to restart (the broker,
    which ends all grants, and each nodkey ssh-agent). It restarts
    nothing.
  12. An EXIT trap removes the temporary directory and a new file that was
    not renamed yet, also after a failure or a signal.

All work is in functions, and main "$@" is the last line. Every failure
ends with nodkey: <reason>; nothing was installed (exit 1).

install.go (new): package nodkey at the root of the module, which
embeds install.sh as InstallScript.

nodkey update [--version X.Y.Z] [--gh PATH] [--reinstall]
(cmd/nodkey/update.go): only as root; resolves its own file with
os.Executable and filepath.EvalSymlinks and refuses a file that is not
named nodkey; runs /bin/sh -c <embedded script> nodkey-install --bin-dir <dir> [flags] with stdin from /dev/null, and passes stdout,
stderr and the exit code through. The script gets a fixed environment:
PATH=/usr/sbin:/usr/bin:/sbin:/bin, LC_ALL=C, HOME=/root, and only
HTTPS_PROXY, https_proxy, NO_PROXY and no_proxy if set. So with
sudo -E or after su without -, another user's TAR_OPTIONS,
LD_PRELOAD or TMPDIR do not reach root's programs.

CI: the lint job downloads ShellCheck v0.11.0, checks its SHA-256 in
the step, and runs shellcheck --shell=sh install.sh.

Docs: docs/setup.md sections 1, 2, 13 and 14; README.md quick
start; DESIGN.md sections 4, 15 and 18; THREAT-MODEL.md T20 and
section 7; AGENTS.md layout. Section 2 and the README now say where the
root shell should come from: on the broker host its console or a trusted
computer, never an agent machine; on an agent machine its console or SSH
as an admin user from a trusted computer, not sudo in the agent's
terminal (the agent could put its own sudo first in PATH, or type into
the terminal). They also state the limit: on an agent machine that the
agent controls, nodkey cannot keep its client safe, but the broker trusts
nothing from the client, so a changed client gets no more than the phone
approves. Section 13 sets VERSION once, runs
nodkey update --version "$VERSION" (no sudo on the broker host) and
fetches the unit of the same version, with no # comment after a command
(that breaks in interactive zsh). Section 14 says what to do when the
installer refuses a path (for example an old Debian with /usr/local/bin
writable by staff; Debian 12, Debian 13 and Ubuntu 24.04 ship it
755 root:root, checked in their official images).

How the gh pin was checked

On 2026-10-17, with gh 2.96.0, logged in:

  • gh release view --repo cli/cli --json tagName,publishedAt,isImmutable:
    v2.102.0, published 2026-09-30, immutable; the newest release.
  • sha256sum of gh_2.102.0_linux_amd64.tar.gz and
    gh_2.102.0_linux_arm64.tar.gz equals their lines in
    gh_2.102.0_checksums.txt and the asset digests on GitHub:
    • amd64 bb766f710eef8ede859c18578c72c327597cd4c8a85b06001b1f3843c6019386
    • arm64 7862c86c72f43df3a2d93ddde6f473285b4e2af61b494849846827e513ef6484
  • gh attestation verify <tarball> --repo cli/cli, --owner cli, and with
    --cert-identity https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk --deny-self-hosted-runners:
    exit 0 for both. The attestation names the same two SHA-256s.
  • "Done when" asks that the checksums file passes
    gh attestation verify --owner cli. It cannot: cli/cli does not attest
    that file (Error: no attestations found). The tarballs were checked
    instead, which is stronger.
  • 2.93.0 as the floor: GHSA-fgw4-v983-mgp8 (exit code 0 with no
    attestations, fixed in 2.67.0) and GHSA-8xvp-7hj6-mcj9 (fixed in 2.93.0).

With the real pinned gh, no login and an empty environment, against the
real 0.1.0 tarball and its provenance bundle, install.sh (with a fake
curl that served these real files):

  • --version 0.1.0: installed;
  • the 0.1.0 files and bundle renamed as 9.9.9: the attestation passed, and
    the version check refused it;
  • the 0.1.0 tarball with one byte added, with a matching SHA256SUMS: gh
    refused it (verifying with issuer "sigstore.dev").

TAR_OPTIONS with a --checkpoint-action=exec command was set in all
three runs, and the command never ran.

Tests

No test uses the network. As root, the script runs only the programs of
the system, so the tests of the script skip as root (the fakes in PATH
would not count, and curl would use the network).

install_linux_test.go runs install.sh with /bin/sh (dash on Debian
and Ubuntu) against a fake curl first in PATH (it serves files of the
test, enforces --max-filesize, and writes down each call), a fake
uname, and a fake gh given with --gh (it writes down its arguments and
environment). The fake release is built in the test.

  • Installs: fresh, update, unknown installed version (dev, a commit, a
    file that does not run), --reinstall of the same version, --version
    to an older one, arm64, uname -m amd64, gh 2.93.0 exactly, a
    TMPDIR with the sticky bit, a bin directory in a sticky directory, a
    bin directory behind a link, and the downloaded gh (a copy of the script
    with the SHA-256 of a fake gh tarball).
  • Nothing to do (exit 0, no release downloaded): same version, also with
    --version; a newer version without --version, also with
    --reinstall. The text says that the installed file was not checked.
  • Deny paths (exit 1; the bin directory is as before; the temporary
    directory is gone): another system or CPU; no bin directory; a bin
    directory that cannot be written, or that others, its group, or all
    with the sticky bit can write to, or under a directory that others can
    write to; an old file that others can write to, that is a link, a
    directory or a pipe; a TMPDIR that others can write to, or under such
    a directory, or missing; a --gh that others can write to, in such a
    directory, a link, or missing (for all of these: no download, and no
    nodkey or gh ran); no API answer, or one that is too big; bad tags;
    missing tarball, SHA256SUMS or bundle; a SHA256SUMS that is too big;
    bad checksum, missing line, a line for a longer name, two lines; failed
    verify; a gh that is too old, of a pre-release, with a suffix, of no
    version, or whose --version fails; an attested older build under a
    newer name
    ; a new nodkey of another version, with more output, with a
    v, or that does not run; a tarball without nodkey; a downloaded gh
    with another SHA-256; no gh to download. A new nodkey never runs when
    the attestation was not checked, and not when it fails.
  • Wrong usage (exit 64, before any download), and --help.
  • The exact arguments of gh attestation verify, that gh checked the very
    file of the release, and its environment: no token, no host, none of the
    unsafe variables, LC_ALL=C, and HOME, the gh config and the XDG
    directories in the temporary directory.
  • Each curl call starts with -q and has the HTTPS, TLS, --fail,
    retry, time and size flags, with 64 MiB for tarballs and 1 MiB else.
  • TAR_OPTIONS with an exec checkpoint action never runs.
  • SIGTERM while gh runs: exit 1, nothing installed, temporary directory
    gone.
  • The root checks, with the functions of the script loaded and uid=0:
    files of the test are refused ("must be owned by root"), /tmp passes
    as a shared directory and fails as one that must be private, and
    /usr/bin, /tmp and / pass. clean_env removes the variables and
    sets LC_ALL=C; with a fake id that says 0, it sets the fixed PATH.
  • The pinned gh is 2.93.0 or newer, with one SHA-256 for each CPU.

cmd/nodkey/update_test.go: wrong usage (also --gh not absolute or
given twice, and --reinstall=maybe), the refusal as another user, the
arguments of the script (behind a link, with --version, --gh,
--reinstall), the fixed environment (with TAR_OPTIONS, LD_PRELOAD,
TMPDIR, PATH, HOME and GH_TOKEN set for nodkey; only the proxy
variables pass), that the script is install.sh byte for byte, the exit
code, the refusal of a file that is not named nodkey or cannot be found,
and the real run with /bin/sh ($0, arguments, only the given
environment, stdin from /dev/null, stdout, stderr, exit code, a signal).

Results:

command result
CI=true go test -race ./... (Go 1.27.1) pass
CI=true go test -race ./... (Go 1.26.0) pass
go vet ./... (both) no findings
gofmt -l . no files
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0 run --build-tags e2e 0 issues
shellcheck --shell=sh install.sh (v0.11.0, SHA-256 checked) no findings
the new tests with -count=20 -cpu 1,2,4 pass
the CI step of ShellCheck, run with bash pass; a wrong SHA-256 fails it

The end-to-end test was not run: this change does not touch what it
covers.

Left out, and why

  • docs/releasing.md is not changed here. ci: publish the attestation bundle with each release #87 (merged in ci: publish the attestation bundle with each release #91) made
    "Verify a download" three steps; T20 and setup.md refer to them.
  • The last item of "Done when" (a real install on a clean Debian host with
    no GitHub login) waits for the next release. 0.1.0 has no bundle, so the
    installer refuses it with a clear message, and the docs say to install
    0.1.0 by hand. 0.1.0 has no nodkey update, so the first update of a
    0.1.0 host uses the installer.
  • No test runs as real root. The root branches are tested through the
    functions of the script with uid=0, and through a hook for the user
    ID in Go.
  • nodkey update does not pass TMPDIR. On a host where /tmp does not
    allow programs to run, the downloaded gh cannot run; the message then
    says to use --gh, or to run install.sh with TMPDIR set to a
    directory that only root can write, such as /root.
  • Dependabot does not update the pinned gh or ShellCheck; comments say how
    to update them by hand.

The security review (.claude/agents/security-reviewer.md) found no
critical or high finding. All medium, low and nit findings of the first
review are fixed in this change. The review of those fixes found one
medium point: OPENSSL_CONF, OPENSSL_MODULES and OPENSSL_ENGINES
were not removed, and an OpenSSL config can make curl load any library.
The script now removes them too, and the test checks them. Its low points
are in #94.

Closes #88

@groupsky
groupsky merged commit cee02b8 into main Oct 17, 2026
7 checks passed
@groupsky
groupsky deleted the feat/install-update branch October 17, 2026 06:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: install script and nodkey update

1 participant