commit e4c9b69b4668e799cbe373d9c56680febef89640 Author: Privatae Date: Tue Oct 20 04:42:14 2026 +0000 bastion 0.53.2 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..3c55232 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,124 @@ +# The checks CONTRIBUTING.md asks for, run on every push to main and every +# pull request into main. It holds no secrets: the only credential is the job's +# own token, used to fetch the commit under test. +# +# Every Go step runs in the Go toolchain image deploy/build.sh builds releases +# with, pinned by digest, so CI and the release use the same compiler. The +# personal-data check is plain Python and runs in the job's own container. +# Each container is capped at 6 CPUs, and GOMAXPROCS says so too, so Go does +# not schedule more threads than the runner is allowed to use. +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + GO_IMAGE: golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59 + # The pull request's own head, or the pushed commit. + WANT: ${{ gitea.event.pull_request.head.sha || gitea.sha }} + +jobs: + test: + runs-on: platform + steps: + - name: fetch the commit + run: | + set -euo pipefail + git init -q . + git -c http.extraHeader="Authorization: token ${{ gitea.token }}" \ + fetch -q --depth=1 "${{ gitea.server_url }}/${{ gitea.repository }}.git" "$WANT" + git checkout -q --detach FETCH_HEAD + test "$(git rev-parse HEAD)" = "$WANT" + - name: gofmt, go vet on linux/darwin/windows, build, go test + # pdftotext, python3, setpriv and git are what the extract and peer-identity + # tests call; without them those tests fail rather than skip. Alpine's + # git has no http-backend (it is in git-daemon), which the forge-token + # test serves its repository with. + # The backup tests check this code against the age command itself: a + # pinned age release, its archive checked against the SHA-256 below, + # and PRIVATAE_REQUIRE_AGE makes those tests fail rather than skip if + # it is missing. + run: | + docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/client-go \ + -e GOTOOLCHAIN=local -e CGO_ENABLED=0 -e PRIVATAE_REQUIRE_AGE=1 "$GO_IMAGE" sh -euc ' + apk add -q --no-cache poppler-utils python3 setpriv curl git git-daemon + case "$(uname -m)" in + x86_64) a=amd64; sum=cbe24006683f8eb669266162894b9a522a1af52f2665fbc63a4bb032ed26ac10 ;; + aarch64) a=arm64; sum=6b8dc4333c53a5a57c9e5834e3a48f92605d7154014cd07269ff3327db5d37f4 ;; + *) echo "no pinned age release for $(uname -m)"; exit 1 ;; + esac + curl -fsSL -o /tmp/age.tgz "https://github.com/FiloSottile/age/releases/download/v1.3.2/age-v1.3.2-linux-$a.tar.gz" + echo "$sum /tmp/age.tgz" | sha256sum -c - + tar -xzf /tmp/age.tgz -C /tmp age/age + export AGE_BIN=/tmp/age/age + "$AGE_BIN" --version + echo "== gofmt"; out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; } + for os in linux darwin windows; do echo "== go vet ($os)"; GOOS=$os go vet ./...; done + echo "== go build"; go build -o /tmp/privatae-bastion . + echo "== go test"; go test -count=1 ./...' + - name: the same for deploy/release-sign + run: | + docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src/deploy/release-sign \ + -e GOTOOLCHAIN=local -e CGO_ENABLED=0 "$GO_IMAGE" sh -euc ' + out="$(gofmt -l .)"; [ -z "$out" ] || { echo "$out"; exit 1; } + for os in linux darwin windows; do GOOS=$os go vet ./...; done + go test -count=1 ./...' + + personal-data: + runs-on: platform + steps: + - name: fetch the commit + run: | + set -euo pipefail + git init -q . + git -c http.extraHeader="Authorization: token ${{ gitea.token }}" \ + fetch -q --depth=1 "${{ gitea.server_url }}/${{ gitea.repository }}.git" "$WANT" + git checkout -q --detach FETCH_HEAD + test "$(git rev-parse HEAD)" = "$WANT" + - name: no person's data in the tree + # Reads files, runs nothing from the tree. The term check needs the + # maintainers' key, which a CI job never holds, so here it is skipped and + # the run says so; the entity-id, provenance, email and phone checks run. + run: python3 scripts/test_no_personal_data.py + - name: no Host source file names in the tree + # The member repository publishes this tree as a release was built, + # and its release step refuses a path into the Host's Python + # (server/... or brain/... .py): this repo has no such files, so the + # path can only name another program's source. Checked here, before a + # merge, instead of after a release is built and signed. git grep exits + # 1 when nothing matches and above 1 when it could not search (no + # repository, a pattern it cannot read); only 1 passes. + run: | + rc=0 + git grep -nIE 'server/[a-z_/]+\.py|brain/[a-z_/]+\.py' || rc=$? + case "$rc" in + 1) ;; + 0) echo "these lines name another program's source file; describe it instead" >&2 + exit 1 ;; + *) echo "git grep could not search the tree (exit $rc), so it was not checked" >&2 + exit 1 ;; + esac + + fuzz: + needs: test + runs-on: platform + steps: + - name: fetch the commit + run: | + set -euo pipefail + git init -q . + git -c http.extraHeader="Authorization: token ${{ gitea.token }}" \ + fetch -q --depth=1 "${{ gitea.server_url }}/${{ gitea.repository }}.git" "$WANT" + git checkout -q --detach FETCH_HEAD + test "$(git rev-parse HEAD)" = "$WANT" + - name: every fuzz target, 5 seconds each + # A short pass on every commit. Run deploy/fuzz.sh with its default of + # 20 seconds before changing a wall a host's input goes through. + run: | + docker run --rm --cpus 6 -e GOMAXPROCS=6 -v "$PWD":/src -w /src \ + -e GOTOOLCHAIN=local -e CGO_ENABLED=0 "$GO_IMAGE" sh -euc ' + apk add -q --no-cache bash + deploy/fuzz.sh 5' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..91744da --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Build output +client-go/dist/ +client-go/privatae-bastion +client-go/privatae-bastion.exe + +# Editor / OS +.DS_Store +.vscode/ +.idea/ +*.swp diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e3c3d92 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1621 @@ +# Changelog + +All notable changes to the bastion. + +The version in `deploy/VERSION` on `main` is the next release, not necessarily +the one machines run. A merged change waits there until it is published. The +published version is whatever names +at the moment, and it is the only version a bastion will update to. Between a +merge and a publish, `main` is ahead of the manifest. + +Two things can keep a version listed here from reaching most machines: + +- **Superseded before publishing.** When a second release is merged before the + first is published, only the later one is published, and it carries the + changes of both. +- **Held.** Automatic updates move our own machines to a published version + first, and everyone else only after it has run on them for a day without a + machine going silent. A version that fails that check is held: automatic + updates move no other machine to it, and it is never released again, only + superseded by a newer version. + +From 0.39.1 on, a heading marks a version *not published* or *held* once +that is settled. The newest entries may not be published yet; the manifest +says which version is. Older entries are not marked. + +## 0.53.2 + +- **No change to what the bastion does.** Two code comments named source files + of the Host program; they now describe them instead, so the release's source + can be published to the member repository as built. CI checks for such + names before a change merges. + +## 0.53.1 + +- **`switch` turns a capability family off for a host from a terminal.** + `sudo -H privatae-bastion switch on|off` writes the same + local deny list the console page's switch does, so it can only remove + reach: off makes this machine refuse that family for that host and tell it + why, and on clears the refusal without granting anything past the host's + signed grant. Both directions need machine authority. `switch --list` + prints every paired host's families, on or off, one per line, for a person + or a setup script on a machine with no browser; beside a system service on + macOS or Windows it is refused to an ordinary account, whose own state is + not the machine's. The deny list is now replaced whole (temp file and + rename) under a lock both writers take, so the gate never reads it half + written and a switch from the page and one from a terminal cannot drop + each other. +- **`release-sign` signs C-Series Cloud manifests.** It accepts the kind + `cseries-cloud` beside `bastion` and `bastion-agent`, with the same + statement and keys, for the manifest a C-Series Cloud machine's updater + checks before it moves to new images or a new overlay. +- **`release-sign` signs the C-Series App Store catalog.** It accepts the kind + `cseries-store`, with the same statement and keys, for the catalog a + C-Series Host checks before it lists or installs an app from the public + store. A signature for one kind does not verify as another. +- **Fixed: `stepup-enroll --console-only` refused root on Linux.** The + startup check recorded whether the process administers the machine only + when a macOS or Windows system service was installed, and on Linux the + service is the systemd unit, so that check never ran there and root was + told it does not administer the machine. Root on Linux enrols into its own + state, which is the state the unit reads. An ordinary account on Linux is + still refused, and so is every account on macOS and Windows until the + system service is installed. `approvals-owner` shares the rule and now takes + root on Linux the same way. +- **`pair` takes the token on standard input.** `pair -` reads it as the first + line of standard input, so the token is not in the process list while + `pair` runs or in the shell's history after. Every form of `pair` takes `-` + in place of ``, and the token as an argument works as before. `pair` + with no token is still the usage error, whatever standard input is: some + terminals do not register as one, and reading there would wait with nothing + said. At a terminal, `pair -` asks for the token on standard error. A first + line that is empty, more than one word or longer than 4096 bytes is refused, + and no refusal repeats what was read. + +## 0.53.0 + +- **A Host with no platform in a VM on this machine can pair over TCP.** + `pair --redeem-url https://127.0.0.1:8810/v1/local/redeem --host-ca ` + redeems the token on the Host's enroll port (`POST /v1/local/redeem`, the + platform redeem's body) over TLS that trusts that CA and nothing else, no + system roots, and checks the name `host.cseries.internal` on the Host's + certificate. The URL must be `https://[:]/`, with no user + info, query or fragment. `--host-ca` is required with it and refused with a + unix or platform pairing, and must hold CA certificates only; all of this is + checked before the token is read, so a refusal dials nothing. The redeem + follows no redirect: an enroll port that answers with one is refused as it + answered. The Host's answer must say transport `tcp`, carry the same CA as + `--host-ca` and an operator certificate for this bastion's key that the CA + signed for client use, and name a lane on the host the redeem URL names. + The pairing records transport `tcp`, the lane address and the CA, which is + pinned as for a unix pairing: a `pair --replace` under another CA is + refused before the redeem dials, and a pairing does not change transport + on a `--replace`. +- **Its lanes go to the Host's lane port**, the address its answer names, + with the same mutual TLS as the unix lane socket: this bastion's operator + certificate, which the Host knows it by, the pinned CA as the only root, and + the name `host.cseries.internal`. Then the same bearer and protocol: egress, + management, the two parked harness workers, sessions, git, the heartbeat + and what its reply directs. `status` and `pair` show the lane address. +- **What a TCP pairing does not have yet.** No WireGuard forwards: no ollama, + LLM or PII forward, no service slots, no endpoint rotation, as for a unix + pairing. No coder runs, on any machine, Linux included: a run's control + plane is a tunnel port or a socket in a unix pairing's runs directory, and a + TCP pairing has neither, so run-up for a TCP-paired Host is refused before + any container starts, and so is every run-up while this machine's pairings + cannot be read. Off Linux, coder runs stay refused for every pairing, + as before; running them inside the Host's VM is not built. +- `unix://` redeem URLs work as before. +- **Beside a Windows system service, the signed-in person approves in their + own tray.** The service serves a named pipe, `\\.\pipe\privatae-bastion-approvals`. + Its ACL admits SYSTEM, Administrators and interactive users. Interactive + users get read and write and no right to create an instance of the pipe. + Remote clients are refused. The service identifies each client from the + pipe (`GetNamedPipeClientProcessId` and that process's token) and serves + only a process that runs as the person signed in to its own session, in an + active session, and of those only the account that owns this machine's + approvals (below); every other account is refused for list, deny and + approve alike. Through it a client can list the pending step-up requests, + in the words `approvals` prints. It can approve one request id with a code + from the authenticator enrolled here, checked by the existing step-up check + and bound to the sha256 of the purpose the client was shown. It can deny + one, which needs no code. +- **`privatae-bastion approvals-owner --sid [--replace]`**, with + machine authority, records that account in the service's state + (`approvals_owner.json`); an installer's elevated step passes the SID of the + account that ran it. The same SID again changes nothing. A different SID is + refused and the recorded owner stays, unless `--replace` is given, which is + taken only on a console (an Administrator terminal), never with output + captured as an installer runs it. A record that cannot be read is treated + the same way. With no readable owner, the pipe serves nobody. +- **The pipe's name is never free while the service serves it.** A + replacement instance is created before a failed connect's instance is + closed, as it already was before a connected one is handed over. If another + process holds the name when the service starts, the first create fails (it + insists on a new name), and the service tries again, waiting 1 s and + doubling to 60 s, logging once per series of failed attempts and once when + serving again. It never serves under a name it did not create. +- **What never crosses the pipe.** The TOTP secret, `ui.secret` and approval + ids. Device approvals are not listed, because they need no code. +- **Wrong codes on the pipe are limited** (NIST SP 800-63B 5.2.2). Five are + free. After that the pipe takes one attempt per 30 seconds. After 100 in a + row it takes none. A code accepted on any other lane resets the count. +- **`privatae-bastion tray`**: the person's icon and approvals window, run as + them and drawn from the pipe. `service install` starts it at every sign-in + through the HKLM Run value `PrivataeBastionTray`, and `service uninstall` + removes that value. One tray runs per session. The tray checks that the + pipe's server is the `PrivataeBastion` service process before it sends + anything. The daemon's own tray stays as it was for a bastion that runs as + the person; the service still shows none. +- **`stepup-enroll --console-only`**, for an installer's elevated step. The + secret is shown only when standard input and output are a console. It is + saved only in the system service's state: on Windows and macOS when the + service is installed and the process has machine authority, on Linux when + the process is root. Both checks run before a secret exists. +- **`release-anchor --host (--host-ca | --force)`**, with + machine authority, for an anchor whose Host is gone, e.g. a Host on this + machine deleted with its data and set up again. `--host-ca` is the new + Host's CA. It is refused when that is the CA this box pinned for the host, + since the same CA means the same Host, alive, and when this box pinned no + CA to compare. `--force` skips that check and is taken only on a console; + an installer never passes it. All of this is checked before anything + changes. It drops the anchor if it is that host and refuses any other. + It unpairs that host. It renames that host's DCN state (key pin, factor, + grants) to `dcn/.released-