Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
113 changes: 112 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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: <reason>; nothing was installed (exit 1)` |
| 64 | wrong usage |

## 16. Implementation rules

- No secret value in any log, error text, audit entry or Telegram message.
Expand Down Expand Up @@ -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.
Expand All @@ -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_<version>.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_<version>.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 <version>`. 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

Expand Down
46 changes: 21 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading