bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
0.53.2
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Build every release binary into client-go/dist, the same way every time.
#
# deploy/build.sh <version>
#
# publish.sh calls this to make a release, and verify-release.sh calls it to
# rebuild one and compare. They share this file so the two can never build
# differently.
#
# What makes two builds of the same commit byte-identical:
# - the toolchain image is pinned by digest, not by tag. A tag moves when a
# new Go patch release is pushed, and a different compiler writes different
# bytes.
# - GOTOOLCHAIN=local, so go.mod cannot make the build fetch another compiler.
# - -trimpath, so no path from the machine that built it lands in the binary.
# - -buildvcs=false, so no VCS state is stamped in (only client-go is mounted,
# but this says so instead of relying on it).
# - -mod=readonly with go.sum, so every module is the one go.sum names.
# - CGO_ENABLED=0, so no system C library is linked.
# The version is the only thing passed in, and it is the same for everyone who
# builds that release.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
VER="${1:?usage: deploy/build.sh <version>}"
# golang:1.25-alpine as of the digest below (go1.25.14). Move it deliberately,
# in its own commit, and a release built after that commit is rebuilt with the
# new one.
GO_IMAGE="golang@sha256:1ae0735f00daffa3aaf1363a5184c0d2dc55c78e3db4ec70241cdac97bf84b59"
# The module cache is only a download cache: go.sum checks everything read from
# it. It is kept between runs so a rebuild does not fetch every module again.
MODCACHE="${GOMODCACHE_DIR:-$HOME/go/pkg/mod}"
mkdir -p "$MODCACHE"
# --user: the files in dist/ belong to whoever ran this, not to root, so a
# non-root checkout can clean up after itself.
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GOCACHE=/tmp/gocache \
-v "$HERE/client-go":/src -v "$MODCACHE":/go/pkg/mod -w /src \
-e CGO_ENABLED=0 -e GOTOOLCHAIN=local -e GOFLAGS="-mod=readonly -trimpath -buildvcs=false" \
-e VER="$VER" "$GO_IMAGE" sh -c '
rm -rf /src/dist && mkdir -p /src/dist
for t in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64; do
os=${t%/*}; arch=${t#*/}; ext=""; [ "$os" = windows ] && ext=".exe"
GOOS=$os GOARCH=$arch go build -ldflags "-X main.clientVersion=$VER" \
-o /src/dist/privatae-bastion-$os-$arch$ext .
done'
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Check that every test the README's security table cites exists.
#
# deploy/check-claims.sh [repo root] (default: the repository this sits in)
#
# The README's "How each claim is tested" table names, for each security claim,
# a test in client-go as `file_test.go`: `TestOrFuzzName`. A row is only worth
# something while that function exists: a renamed or deleted test would leave
# the claim looking covered when nothing checks it. This finds every such
# citation in the table and confirms `func <name>(` is defined in the cited
# file. It prints ok or MISSING for each row and exits non-zero if any is
# missing.
#
# A table it can only partly read must not pass as a clean one, so the rows
# are read one by one: every body row's test cell must be either exactly one
# citation in that form or "not covered by a test". A row that is neither is
# named and fails the check, and so does a table with no citations at all.
#
# It checks that the test exists, not what it asserts; that part is reviewed
# by reading the test when a row is added or changed.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
ROOT="${1:-$HERE}"
cd "$ROOT"
[ -f README.md ] && [ -d client-go ] || { echo "not a repository root: $ROOT" >&2; exit 2; }
python3 - <<'PY'
import re, sys
text = open("README.md", encoding="utf-8").read()
start = text.find("### How each claim is tested")
if start < 0:
print("the README has no 'How each claim is tested' section", file=sys.stderr)
sys.exit(1)
end = text.find("\n## ", start)
table = text[start:end if end >= 0 else len(text)]
cite = re.compile(r"`([\w./-]+_test\.go)`: `(\w+)`")
rows, unreadable = [], []
body = [l for l in table.splitlines() if l.startswith("|")][2:] # past header and rule
for line in body:
cells = [c.strip() for c in line.strip().strip("|").split("|")]
test = cells[1] if len(cells) == 3 else ""
if test == "not covered by a test":
continue
m = cite.fullmatch(test)
if m:
rows.append(m.groups())
else:
unreadable.append(line)
for line in unreadable:
print("UNREADABLE " + line)
if not rows:
print("no test citations found in the table", file=sys.stderr)
sys.exit(1)
missing = 0
for path, name in rows:
try:
src = open("client-go/" + path, encoding="utf-8").read()
except OSError:
src = ""
ok = re.search(r"^func " + re.escape(name) + r"\(", src, re.M) is not None
missing += not ok
print(("ok " if ok else "MISSING ") + "client-go/" + path + ": " + name)
print(f"{len(rows) - missing} of {len(rows)} cited tests found")
if unreadable:
print(f"{len(unreadable)} table row(s) cite no test in the expected form", file=sys.stderr)
sys.exit(1 if missing or unreadable else 0)
PY
Executable
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Run every fuzz target in client-go for a bounded time each.
#
# deploy/fuzz.sh [seconds per target] (default 20)
#
# `go test` already runs each target's seed inputs as ordinary tests. This goes
# further: it lets the fuzzer generate new inputs against each target in turn.
# Go fuzzes one target per run, so the targets are run one after another. The
# list comes from `go test -list`, so a target added later is picked up without
# editing this file, and a platform's own targets are the ones it builds.
#
# An input that breaks a target is written under client-go/testdata/fuzz/<target>/
# and the run stops there. Commit that file with the fix: from then on it is one
# of the seeds every `go test` runs.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
SECS="${1:-20}"
cd "$HERE/client-go"
targets="$(go test -list '^Fuzz' . | grep '^Fuzz' || true)"
[ -n "$targets" ] || { echo "no fuzz targets found" >&2; exit 1; }
for t in $targets; do
echo "== $t (${SECS}s)"
go test -run '^$' -fuzz "^${t}\$" -fuzztime "${SECS}s" .
done
echo "every target ran ${SECS}s with no failing input"
+233
View File
@@ -0,0 +1,233 @@
# privatae-bastion installer — Windows. Usage: irm https://privatae.ai/bastion/install.ps1 | iex
#
# -Networked: install the NETWORKED bastion — a machine several hosts share,
# where every host after the first waits until the anchor host approves it. It
# runs as a Windows service under LocalSystem, so it runs whoever is signed in
# and only an administrator can stop it. Needs an ELEVATED terminal
# (right-click → Run as administrator), because whoever administers the box is
# what stands behind the anchor.
# & ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Networked
# (NOT `irm ... | iex -Networked` — Invoke-Expression takes no such parameter and
# that form errors without installing anything.)
# On an install that already exists, `privatae-bastion set-mode networked` (from
# an Administrator terminal) changes the mode but not how it runs: it stays a
# logon task. Re-run this installer with -Networked to make it a service. The
# service starts with its own state: pairings made by the earlier per-user
# install stay in that user's profile and are not carried over, so the machine
# is paired again (claim-anchor with a token from the host).
#
# ELEVATE AS YOURSELF, not as a different administrator account. The state
# directory is per-user (a folder under %APPDATA%), and UAC elevation of your
# OWN account keeps the same profile — but "Run as different user" does not, and
# the bastion would then read an empty directory and refuse to see its pairings.
# This is the Windows shape of the same trap `sudo -H` exists for on Unix.
param([switch]$Networked)
#
# Brought into the repo (this file previously existed ONLY as bytes in the
# edge webroot, so what customers piped to `iex` had no review history) and
# given digest verification (version.json had always published correct
# sha256s and neither installer read them).
$ErrorActionPreference = 'Stop'
# dl.privatae.ai — see the note in install.sh. Hardcoded on purpose: the digest
# is verified against the manifest from this same base, so a redirectable base
# would defeat the verification it is paired with.
$base = 'https://dl.privatae.ai'
$arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') {'arm64'} else {'amd64'}
$key = "windows-$arch.exe"
if ($Networked) {
# Checked before anything is downloaded, so a refusal leaves nothing behind
# and is one clear sentence at the moment it can be acted on.
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
throw ("-Networked needs an elevated terminal. A networked bastion runs as a Windows service, " +
"and whoever administers the machine is what stands behind it.`n" +
"Close this window, right-click your terminal, choose 'Run as administrator', and run it again.")
}
}
# A networked bastion runs as a Windows service under LocalSystem, so its
# program goes under Program Files, which only administrators can change. In
# the per-user folder anyone signed in could replace the file the service runs
# as the system, and the bastion refuses to register a service from there.
$dest = if ($Networked) { Join-Path $env:ProgramFiles 'Privatae' } else { Join-Path $env:LOCALAPPDATA 'Programs\privatae-bastion' }
New-Item -ItemType Directory -Force -Path $dest | Out-Null
$exe = Join-Path $dest 'privatae-bastion.exe'
Write-Host "Fetching manifest..."
try { $manifest = Invoke-RestMethod -Uri "$base/version.json" }
catch { throw "cannot fetch $base/version.json - refusing to install unverified" }
$expected = $manifest.binaries.$key.sha256
if (-not $expected -or $expected -notmatch '^[0-9a-f]{64}$') {
throw "manifest has no usable sha256 for $key - refusing to install unverified"
}
# Download to a TEMP path and only move it into place after the digest matches,
# so a failed verification can never leave a runnable executable behind.
# For -Networked the download is made inside the destination folder. A file
# moved within one volume keeps the permissions it was created with, so one
# made under %TEMP% would carry this account's full control into Program Files
# and let this account, unelevated, replace the program the service runs as
# the system. Made here, it has the folder's permissions from the start.
$tmpDir = if ($Networked) { $dest } else { [System.IO.Path]::GetTempPath() }
$tmp = Join-Path $tmpDir ("privatae-bastion-" + [System.Guid]::NewGuid().ToString() + ".exe")
try {
# Take the URL FROM THE MANIFEST, never build one. The manifest's url now
# carries ?v=<version>, which is what stops Cloudflare handing back the
# PREVIOUS release's binary against the CURRENT release's digest — a stale
# cache that surfaces as "checksum MISMATCH" and reads like a corrupt or
# tampered download. Building the path here would silently opt out of that.
# Falls back to the constructed path for a manifest that predates the field.
$url = $manifest.binaries.$key.url
if (-not $url) { $url = "$base/dl/privatae-bastion-$key" }
Write-Host "Downloading privatae-bastion ($key)..."
Invoke-WebRequest -Uri $url -OutFile $tmp
$actual = (Get-FileHash -Algorithm SHA256 -Path $tmp).Hash.ToLower()
if ($actual -ne $expected.ToLower()) {
throw ("checksum MISMATCH for privatae-bastion-$key`n expected $expected`n actual $actual`n" +
"The downloaded binary is not the one the platform published. Nothing was installed.")
}
Write-Host "Checksum verified ($expected)."
# A running service holds its program open, and Windows will not replace an
# open program. Stop it first; `service install` below starts it again.
if ($Networked -and (Get-Service -Name 'PrivataeBastion' -ErrorAction SilentlyContinue)) {
Stop-Service -Name 'PrivataeBastion' -Force -ErrorAction SilentlyContinue
}
Move-Item -Force -Path $tmp -Destination $exe
} finally {
if (Test-Path $tmp) { Remove-Item -Force $tmp -ErrorAction SilentlyContinue }
}
# ── AUTOSTART: a Scheduled Task at logon ──────────────────────────────────────
# Until now this installer registered NOTHING: no service, no task, no Run key.
# So the bastion lived in whatever terminal you happened to start it in, died
# with that window, and never came back after a reboot. A person with no
# terminal open had no way to tell where it was running — a fair question
# whose real answer was that 0.24.0 restarts itself DETACHED after a self-update,
# so it was serving with no console and nothing on screen saying so.
#
# A SCHEDULED TASK, NOT A WINDOWS SERVICE, and that is not laziness. The
# bastion's state directory is per-USER (under %APPDATA% — see the note at the
# top of this file). A service registered with the SCM runs as SYSTEM or
# a service account, which has a DIFFERENT %APPDATA%, so it would start cleanly
# and then read an empty state directory and see no pairings at all. That is the
# same "Run as different user" trap this installer already warns about, wearing
# a service's clothes. A logon task runs as the user, so the state is the state.
#
# -Force replaces an existing registration, so re-running the installer updates
# the task instead of failing on a duplicate.
#
# -NETWORKED IS THE EXCEPTION, AND THE SERVICE IS THE POINT OF IT. A machine an
# organisation enrols needs the bastion to run whoever is signed in, and not be
# stoppable by them. So there the bastion registers itself as a Windows service
# running as LocalSystem (`privatae-bastion service install`). The state-folder
# trap above does not apply: the bastion keeps a service's state in
# LocalSystem's own profile and points an administrator's commands at the same
# folder (client-go/sysservice.go). A logon task left over from an earlier
# install would start a second bastion beside the service, so it is removed.
#
# Removing the task does not stop a copy it already started, and after a
# self-update that copy runs detached, outside the task altogether. It holds
# 127.0.0.1:7878, which is the port the service's first pairing is given, so the
# service would fail to bind and restart every few seconds until that copy
# exited at sign-out. So any bastion running from another path is stopped here.
if ($Networked) {
$task = Get-ScheduledTask -TaskName 'PrivataeBastion' -ErrorAction SilentlyContinue
if ($task) {
Unregister-ScheduledTask -TaskName 'PrivataeBastion' -Confirm:$false
Write-Host "Removed the logon task from an earlier install; the service replaces it."
}
Get-Process -Name 'privatae-bastion' -ErrorAction SilentlyContinue |
Where-Object { $_.Path -and ($_.Path -ne $exe) } |
ForEach-Object {
Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue
Write-Host "Stopped the bastion running from $($_.Path) (an earlier per-user install)."
}
& $exe service install
if ($LASTEXITCODE -ne 0) { throw "could not register the Windows service (exit $LASTEXITCODE)" }
} else {
try {
$taskName = 'PrivataeBastion'
# DOMAIN\user rather than a bare name: a bare one is ambiguous on a machine
# that is domain-joined, and the task would be registered for the wrong
# identity — which is the same empty-%APPDATA% trap as a service.
$me = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name
$action = New-ScheduledTaskAction -Execute $exe -Argument 'run'
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $me
# LogonType Interactive is LOAD-BEARING, not a default worth inheriting. A
# task that runs with a background (S4U) or service logon has no window
# station, so the tray icon (tray_windows.go) would never appear and the
# bastion would be exactly as invisible as it was before any of this — while
# still reporting itself as started. Interactive puts it in the session the
# person is actually looking at. RunLevel Limited because the bastion needs
# no elevation to serve; only `claim-anchor` and friends do, and those are
# typed by hand.
$principal = New-ScheduledTaskPrincipal -UserId $me -LogonType Interactive -RunLevel Limited
# RunOnlyIfNetworkAvailable is deliberately OFF: the tunnel dials out and
# retries, and a machine that boots before its NIC is up would otherwise
# never start the bastion at all.
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries -StartWhenAvailable `
-ExecutionTimeLimit ([TimeSpan]::Zero)
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
-Principal $principal `
-Settings $settings -Description 'Privatae bastion — serves this machine to your Hosts.' `
-Force | Out-Null
Write-Host "Autostart registered: Task Scheduler -> $taskName (runs at your logon)."
Write-Host " Stop it with: Stop-ScheduledTask -TaskName $taskName"
Write-Host " Remove it with: Unregister-ScheduledTask -TaskName $taskName -Confirm:`$false"
} catch {
# Never fail the install over autostart — the binary is on disk and usable.
Write-Host "NOTE: could not register autostart ($($_.Exception.Message))."
Write-Host " The bastion still works; start it with 'privatae-bastion run'."
}
}
# A networked install is the machine's, so its command goes on the MACHINE
# path, where an administrator's terminal finds it whoever installed it.
#
# The machine Path is read and written in the registry as it is stored, NOT
# through [Environment]::GetEnvironmentVariable: that returns it with every
# %VARIABLE% already expanded, and writing it back stores the expanded text as
# a plain string, which fixes those entries for the whole machine. The new
# value takes effect in new sessions; this terminal is updated below.
if ($Networked) {
$envKey = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey(
'SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true)
try {
$raw = [string]$envKey.GetValue('Path', '',
[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
if (($raw -split ';') -notcontains $dest) {
$envKey.SetValue('Path', ($raw.TrimEnd(';') + ';' + $dest),
[Microsoft.Win32.RegistryValueKind]::ExpandString)
}
} finally {
$envKey.Close()
}
} else {
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($userPath -notlike "*$dest*") { [Environment]::SetEnvironmentVariable('Path', ($userPath.TrimEnd(';') + ';' + $dest), 'User') }
}
$env:Path = "$env:Path;$dest"
Write-Host ""
Write-Host "Installed: $exe"
if ($Networked) {
# After `service install`, so this elevated command already writes into the
# service's state folder rather than this administrator's own profile.
& $exe set-mode networked
if ($LASTEXITCODE -ne 0) { throw "could not set networked mode (exit $LASTEXITCODE)" }
Write-Host ""
Write-Host "The bastion runs as the Windows service 'PrivataeBastion' (LocalSystem). It starts"
Write-Host "serving as soon as this machine is paired."
Write-Host "Next, anchor this machine to the Host that should govern it:"
Write-Host " privatae-bastion claim-anchor"
Write-Host " (add --host <name> if this machine already serves several Hosts)"
Write-Host "Every host paired after the anchor waits, pending, until that Host approves it."
} else {
Write-Host "Open a NEW terminal, then: privatae-bastion pair <token>"
}
Write-Host "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt"
+193
View File
@@ -0,0 +1,193 @@
#!/bin/sh
# privatae-bastion installer (macOS / Linux). Installs the binary AND — on Linux as root —
# the always-on systemd service, and with --networked on macOS a launchd daemon
# running as root, so onboarding a node is two commands:
# curl -fsSL https://privatae.ai/bastion/install.sh | sudo sh
# sudo -H privatae-bastion pair <token>
#
set -e
# dl.privatae.ai, not privatae.ai/bastion: the download host is served from each
# edge's OWN local disk, so installs survive losing the platform datacentre, and
# DNS points it at the edge with the bandwidth for an install wave (~15MB a
# binary, and the primary sustains roughly 25x what the secondary does).
# Deliberately NOT env-overridable: the digest is checked against the manifest
# from this same base, so anything that can redirect the base can redirect both
# and the verification buys nothing. Hardcoded is the point.
base="https://dl.privatae.ai"
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m); case "$arch" in x86_64|amd64) arch=amd64;; aarch64|arm64) arch=arm64;; esac
if [ "$(id -u)" = 0 ]; then bindir=/usr/local/bin; root=1; else bindir="${HOME}/.local/bin"; root=0; mkdir -p "$bindir"; fi
bin="$bindir/privatae-bastion"
# --networked: install the NETWORKED bastion — a root-owned
# service on a machine several people share, anchored to one host that approves
# every further pairing. Everything else is the LOCAL bastion: one host, and on a
# home machine it runs as the user. Usage: curl ... | sudo sh -s -- --networked
networked=0
for a in "$@"; do
case "$a" in
--networked) networked=1 ;;
*) echo "unknown option: $a" >&2; exit 2 ;;
esac
done
if [ "$networked" = 1 ] && [ "$root" != 1 ]; then
echo "ERROR: --networked needs root — a networked bastion is a root-owned service (run with sudo)" >&2; exit 1
fi
key="$os-$arch"
tmp=$(mktemp); man=$(mktemp)
# Any exit before the final mv must leave nothing behind and install nothing:
# this script is piped to `sudo sh`, so a partial run is a root-privileged
# partial install.
trap 'rm -f "$tmp" "$man"' EXIT INT TERM
die() { echo "ERROR: $*" >&2; exit 1; }
# Pick the digest tool ONCE, here — not inside the command substitution that
# computes the hash. `exit` inside `$(...)` only leaves the subshell, so a
# missing-tool check there would print an error and then carry on installing
# an unverified binary, which is the exact failure this whole change exists to
# prevent. Linux ships sha256sum; macOS ships shasum. Neither: refuse.
if command -v sha256sum >/dev/null 2>&1; then SHACMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then SHACMD="shasum -a 256"
else die "no sha256sum or shasum on this system — cannot verify the download, refusing to install"
fi
sha_of() { $SHACMD "$1" | cut -d' ' -f1; }
echo "Fetching manifest..."
curl -fsSL "$base/version.json" -o "$man" || die "cannot fetch $base/version.json — refusing to install unverified"
# Pull this platform's expected digest out of version.json without needing jq or
# python (neither is guaranteed on a clean macOS or a minimal container): find the
# stanza for our key, then take the quoted value on its "sha256" line.
expected=$(awk -v k="\"$key\"" '
index($0, k) { f = 1 }
f && /"sha256"/ { n = split($0, a, "\""); print a[n-1]; exit }
' "$man")
case "$expected" in
*[!0-9a-f]* | "") die "manifest has no usable sha256 for $key — refusing to install unverified" ;;
esac
[ ${#expected} -eq 64 ] || die "manifest sha256 for $key is malformed — refusing to install unverified"
# Take the URL FROM THE MANIFEST, never build one. The manifest's url now carries
# ?v=<version>, which is what stops Cloudflare handing back the PREVIOUS release's
# binary against the CURRENT release's digest — a stale cache that surfaces as
# "checksum MISMATCH" and reads like a corrupt or tampered download. Same awk
# shape as the digest read above (no jq, no python on a clean macOS). Falls back
# to the constructed path for a manifest that predates the field.
url=$(awk -v k="\"$key\"" '
index($0, k) { f = 1 }
f && /"url"/ { n = split($0, a, "\""); print a[n-1]; exit }
' "$man")
case "$url" in
https://*) ;;
*) url="$base/dl/privatae-bastion-$key" ;;
esac
echo "Downloading privatae-bastion ($key)..."
curl -fsSL "$url" -o "$tmp" || die "download failed for $key"
actual=$(sha_of "$tmp")
if [ "$actual" != "$expected" ]; then
die "checksum MISMATCH for privatae-bastion-$key
expected $expected
actual $actual
The downloaded binary is not the one the platform published. Nothing was installed."
fi
echo "Checksum verified ($expected)."
darwin_service=0
if [ "$os" = darwin ] && [ "$networked" = 1 ]; then
# The launchd daemon runs this program as root, so only root may be able to
# replace it: a root-owned folder, not /usr/local/bin, which a common package
# manager hands to the signed-in user on many Macs. `service install` checks
# the whole path and refuses otherwise.
darwin_service=1
appdir="/Library/Application Support/privatae"
mkdir -p "$appdir/bin"
chown root:wheel "$appdir" "$appdir/bin"
chmod 755 "$appdir" "$appdir/bin"
bin="$appdir/bin/privatae-bastion"
fi
chmod +x "$tmp"
mv "$tmp" "$bin"
if [ "$darwin_service" = 1 ]; then
chown root:wheel "$bin"
chmod 755 "$bin"
# The command people type still works: a link on PATH to the real program.
# -n replaces an existing link instead of following it: /usr/local/bin may
# belong to the signed-in user, who could otherwise point the name at a
# directory and have this root shell create the link inside it.
mkdir -p /usr/local/bin && ln -sfn "$bin" /usr/local/bin/privatae-bastion
fi
echo "Installed: $bin"
if [ "$os" = linux ] && [ "$root" = 1 ] && command -v systemctl >/dev/null 2>&1; then
# This unit has NO User= line: the service runs as root. Measured on real
# installs: `ps` shows root running the bastion, and every such install came
# from this same script. `run` itself needs no root — the
# tunnel is userspace — but the networked bastion's ops (observe:*, session:*)
# do, and a root install is what makes claim-anchor's root check mean
# something. A single-user machine that wants the local, non-root shape runs
# the installer WITHOUT sudo and gets the --user path below instead.
cat > /etc/systemd/system/privatae-bastion.service <<UNIT
[Unit]
Description=Privatae bastion - your secure door into your private mesh (tunnel + lanes)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Runs as root on purpose (no User=): the networked bastion's ops need it and the
# anchor claim is root-proved. HOME=/root puts the state under /root/.config.
Environment=HOME=/root
ExecStart=$bin run
Restart=always
RestartSec=2
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
if [ "$networked" = 1 ]; then
# The mode is box state under the same HOME the unit uses, written by the
# binary itself so the path is resolved exactly one way.
HOME=/root "$bin" set-mode networked || die "could not set networked mode"
fi
systemctl enable --now privatae-bastion >/dev/null 2>&1 || true
echo "Service installed + enabled (it restarts until you pair, then connects automatically)."
echo ""
if [ "$networked" = 1 ]; then
echo "Networked bastion. Anchor it to YOUR host first (root, one step):"
echo " sudo -H privatae-bastion claim-anchor"
echo " (add --host <name> if this machine already serves several Hosts)"
echo "Every host paired after that waits, pending, until your host approves it."
else
echo "Now pair: sudo -H privatae-bastion pair <token>"
fi
elif [ "$darwin_service" = 1 ]; then
# The mode is box state under root's home, where the daemon keeps its state
# (the daemon's HOME is /var/root), written by the binary itself so the path
# is resolved exactly one way. Before the service starts, so its first run
# already sees it.
HOME=/var/root "$bin" set-mode networked || die "could not set networked mode"
"$bin" service install || die "could not register the system service"
echo ""
echo "Networked bastion, running as a system service (launchd, as root)."
echo "Anchor it to YOUR host first (root, one step):"
# The full path, not the name on PATH: /usr/local/bin may belong to the
# signed-in user, who could point the link at their own program, and this
# line is run as root.
echo " sudo -H '$bin' claim-anchor"
echo " (add --host <name> if this machine already serves several Hosts)"
echo "Every host paired after that waits, pending, until your host approves it."
elif [ "$networked" = 1 ]; then
die "--networked needs a system service manager: systemd on Linux, launchd on macOS"
else
case ":$PATH:" in *":$bindir:"*) ;; *) echo "Add to PATH: export PATH=\"\$PATH:$bindir\"" ;; esac
echo ""
echo "Now pair: privatae-bastion pair <token>"
echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)"
fi
echo "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt"
+337
View File
@@ -0,0 +1,337 @@
#!/usr/bin/env bash
# Cross-build the bastion for all platforms (VERSION-stamped) + publish to the edge
#
# RENAMED: the published artifact is privatae-bastion-<os>-<arch>. The files
# under the previous binary name are deliberately NOT pruned from the webroot's dl/
# — an installer or a doc still pointing at the old filename keeps resolving, and
# self-update swaps a bastion's own executable in place (update.go) so a field
# machine on the old name upgrades onto the new binary without renaming itself.
# (privatae.ai/bastion/) with a version manifest. Run after committing client-go changes.
# Bump deploy/VERSION first. Replaces the old hand-copy.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
# WHERE THIS PUBLISHES IS NOT BAKED IN. Everyone who runs a bastion can read
# this repository, so it names none of our hosts and no key path of ours: EDGES
# and KEY are required, and an unset one stops the script with a message instead
# of publishing somewhere wrong. Whoever cuts a release supplies them.
#
# EDGES: publish to EVERY front door. Each edge serves /bastion from its OWN
# local disk -- that locality is the whole point, it is what keeps the installer
# serving during a platform outage -- so a one-edge publish leaves the fleet
# serving two different binaries behind a single manifest. The installer
# verifies the digest, so that fails CLOSED (the stale edge refuses to
# install rather than installing the wrong bytes), but it is still an outage for
# whoever DNS happens to point at. Publish to all, verify each. Override with
# EDGE=<one-target> for a deliberate single-box publish.
# EDGE (one box) satisfies the requirement on its own — it is the documented
# single-box override above, and requiring EDGES first would have killed it with
# an error naming a variable the operator was never told to set.
if [ -n "${EDGE:-}" ]; then EDGES="$EDGE"; fi
: "${EDGES:?set EDGES to the space-separated edge hosts to publish to (or EDGE=<one> for a single box)}"
: "${KEY:?set KEY to the ssh key that reaches the edges}"
# The webroot and the backup directory are supplied for the same reason EDGES and
# KEY are: everyone who runs a bastion reads this file, so it names no path of
# ours.
: "${WEBROOT:?set WEBROOT to the directory on each edge that serves /bastion}"
: "${BACKUP_DIR:?set BACKUP_DIR to a directory OUTSIDE the webroot for rotated installers}"
# OUTSIDE is load-bearing, not advice. Backups used to land beside the live files
# and every superseded installer was served at a stable public URL -- the earlier
# install.sh, the one with no checksum verification, at HTTP 200 (measured).
# While the backup directory was hardcoded outside the webroot that could not
# recur by construction; as an operator-supplied value it can, so the guarantee
# is re-asserted here.
case "$BACKUP_DIR" in
"$WEBROOT"|"$WEBROOT"/*)
echo "BACKUP_DIR ($BACKUP_DIR) is inside WEBROOT ($WEBROOT) -- rotated installers would be served publicly" >&2
exit 1;;
esac
# A release is published in two runs, because the release key never comes to
# this machine: it stays on the key holder's own computer.
#
# 1. deploy/publish.sh builds, writes the manifest, and prints
# the one release-sign command to run
# 2. SIGNATURE=<line> deploy/publish.sh builds again, checks SIGNATURE against
# the keys this tree pins, and publishes
#
# The build is reproducible, so the second run's manifest is byte-for-byte the
# one that was signed; if it is not (a commit landed in between), the signature
# does not verify and nothing is published.
VER="$(tr -d '[:space:]' < "$HERE/deploy/VERSION")"
GITSHA="$(git -C "$HERE" rev-parse --short HEAD 2>/dev/null || echo unknown)"
# The license we are about to publish, digested HERE from the repo, so both
# verifications below compare the edges and the public URL against this tree
# rather than against each other. Digested with python3 for the same reason the
# manifest is built with it: python3 is already required by this script, while
# sha256sum/shasum differ across the machines a release might be cut from.
LIC_SHA="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$HERE/LICENSE")"
echo "building bastion v$VER ($GITSHA)"
# RATCHET: refuse to publish a build that is not a descendant of the
# currently-published built_from. A rebuild from a DIVERGENT branch was once
# republished with a HIGHER version but a SMALLER feature set (lost the worker lanes),
# regressing every field bastion. A proper forward move (including a merge) passes;
# a divergent tip fails. Override an intentional non-linear publish with ALLOW_NONLINEAR=1.
CUR_FROM="$(curl -fsS --max-time 15 https://privatae.ai/bastion/version.json | python3 -c 'import sys,json;print(json.load(sys.stdin).get("built_from",""))' 2>/dev/null || true)"
if [ -n "$CUR_FROM" ]; then
if git -C "$HERE" merge-base --is-ancestor "$CUR_FROM" HEAD 2>/dev/null; then
echo "ratchet ok: published $CUR_FROM is an ancestor of $GITSHA (forward move)"
else
echo "RATCHET REFUSED: $GITSHA is not a descendant of published built_from $CUR_FROM (would regress the field). Merge/rebase forward, or ALLOW_NONLINEAR=1 to override." >&2
[ "${ALLOW_NONLINEAR:-}" = "1" ] || exit 1
fi
fi
# Reproducible: deploy/verify-release.sh rebuilds a published release with the
# same script and compares every digest.
"$HERE/deploy/build.sh" "$VER"
# version manifest: {version, built_from, binaries:{os-arch:{url,sha256}}} — built with python
# (clean JSON; the host + the bastion self-update read this to decide "out of date").
python3 - "$VER" "$GITSHA" "$HERE/client-go/dist" > "$HERE/client-go/dist/version.json" <<'PY'
import sys, os, hashlib, json
ver, sha, dist = sys.argv[1], sys.argv[2], sys.argv[3]
binaries = {}
for f in sorted(os.listdir(dist)):
if not f.startswith("privatae-bastion-"):
continue
h = hashlib.sha256(open(os.path.join(dist, f), "rb").read()).hexdigest()
# ?v=<version> IS LOAD-BEARING, not decoration. dl.privatae.ai sits behind
# Cloudflare, which caches these objects and keys on the full URL including
# the query string. Without it a publish replaces the bytes on both edges,
# both edges verify, the manifest is fresh — and Cloudflare keeps serving the
# PREVIOUS binary to anyone whose request lands on a warm edge. The client
# downloads the old bytes, compares them against the new digest, and aborts
# with "sha256 mismatch" — which reads like a corrupt build and is actually a
# stale cache. Measured publishing 0.23.0: windows-amd64.exe came
# back cf-cache-status HIT, age 3917, carrying 0.22.0's bytes. It was the
# ONLY stale one, because it was the only object anything had requested since
# the previous publish — so this breaks one platform at a time, at random,
# for whoever happens to have downloaded last. The worst possible shape.
#
# A new version string is a new cache key is a guaranteed miss. Needs no
# Cloudflare credential and cannot be forgotten at publish time. update.go
# downloads binaries[key].url verbatim, so bastions already in the field pick
# this up from the manifest with no client change.
binaries[f[len("privatae-bastion-"):]] = {
"url": f"https://dl.privatae.ai/dl/{f}?v={ver}", "sha256": h}
json.dump({"version": ver, "built_from": sha, "binaries": binaries}, sys.stdout, indent=2)
PY
MAN="$HERE/client-go/dist/version.json"
MAN_SHA="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$MAN")"
if [ -z "${SIGNATURE:-}" ]; then
cat <<ASK
Built bastion v$VER. Nothing has been published.
On the computer that holds the release key, run:
release-sign sign bastion $VER $MAN_SHA
then run this again with the line it prints:
SIGNATURE='<that line>' deploy/publish.sh
ASK
exit 3
fi
# The signature file is named by the manifest's digest (release_sign.go
# releaseSigURL), and old ones are never removed, so a machine that read the
# previous manifest a moment ago still finds the signature that belongs to it.
SIG_NAME="version.json.${MAN_SHA:0:16}.sig"
printf '%s\n' "$SIGNATURE" > "$HERE/client-go/dist/$SIG_NAME"
# Checked against this tree's keys AND the keys of the release the fleet runs
# now (CUR_FROM, read by the ratchet above): those bastions read this manifest
# with their own keys, so a signature only the new keys accept would strand
# them.
SIGNED_BY="$("$HERE/deploy/release-verify.sh" bastion "$MAN" "$HERE/client-go/dist/$SIG_NAME" ${CUR_FROM:+"$CUR_FROM"})" \
|| { echo "SIGNATURE does not verify for bastion v$VER (manifest $MAN_SHA); nothing was published" >&2; exit 1; }
echo "manifest signature ok (key $SIGNED_BY)"
# A bastion from this tree refuses an agent manifest that is not signed, so the
# agent must already be published signed at the address it reads, or every
# new folder grant on an updated machine fails to install the agent. Checked
# here, before this release can reach any machine. (The legacy /freeagent
# address is read only by bastions older than 0.42.0; this release never asks
# it.)
AGENTCHECK="$(mktemp -d)"
for B in https://privatae.ai/bastion-agent; do
curl -fsS --max-time 30 "$B/version.json" -o "$AGENTCHECK/m" \
|| { echo "agent manifest $B/version.json is unreachable; publish the signed agent first" >&2; rm -rf "$AGENTCHECK"; exit 1; }
AH="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest()[:16])' "$AGENTCHECK/m")"
if ! curl -fsS --max-time 30 "$B/version.json.$AH.sig" -o "$AGENTCHECK/s" \
|| ! "$HERE/deploy/release-verify.sh" bastion-agent "$AGENTCHECK/m" "$AGENTCHECK/s" ${CUR_FROM:+"$CUR_FROM"} >/dev/null; then
echo "the agent manifest at $B is not signed with a key this release pins; publish the signed agent first" >&2
rm -rf "$AGENTCHECK"; exit 1
fi
done
rm -rf "$AGENTCHECK"
echo "agent manifests are signed at both addresses"
for E in $EDGES; do
echo "--- publishing to $E ---"
ssh -i "$KEY" "$E" 'rm -rf /tmp/bn && mkdir -p /tmp/bn'
# install.ps1 ships from the repo too: it once existed only as bytes in the
# webroot, so the Windows installer customers piped to `iex` had
# no review history and no deploy path.
# LICENSE ships with the binaries: GPL-3 sections 4 and 5 require the license to
# travel with the program, and a downloaded binary carries no text of its own.
# It lands at /bastion/LICENSE.txt, next to version.json — the URL both
# installers print after a successful install. The .txt is what makes a browser
# render it inline instead of offering a download.
scp -i "$KEY" "$HERE"/client-go/dist/privatae-bastion-* "$HERE"/client-go/dist/version.json \
"$HERE/client-go/dist/$SIG_NAME" \
"$HERE"/deploy/install.sh "$HERE"/deploy/install.ps1 "$HERE"/LICENSE "$E":/tmp/bn/
# WEBROOT/BACKUP_DIR are passed as remote ENVIRONMENT and expand REMOTELY, like
# every other variable in this body. That is deliberate: the heredoc delimiter is
# quoted so nothing here expands locally, which is what keeps $ts/$f/$b working.
# Do not "fix" this by interpolating the paths locally or unquoting the delimiter
# -- unquoting it would expand $ts/$f/$b on THIS box, where they are empty.
ssh -i "$KEY" "$E" "WEBROOT='$WEBROOT' BACKUP_DIR='$BACKUP_DIR' sh -s" <<'REMOTE'
set -e
# A second edge starts with no webroot at all, so create it rather than
# assuming it. The .bak of install.sh is best-effort for the same reason.
install -d -m755 "$WEBROOT/dl"
ts=$(date +%s)
# Backups go OUTSIDE the webroot. They used to land next to the live files,
# which served every superseded installer and binary at a stable public URL:
# /bastion/install.sh.bak-<ts> was the earlier script -- the one with no
# checksum verification -- reachable at HTTP 200 (measured).
# Hardening install.sh is worth nothing while the vulnerable one sits next to
# it. Caddy now 404s \.bak and dotfiles too; this is the other half, so the
# exposure cannot come back if that rule is ever lost.
install -d -m700 "$BACKUP_DIR"
cp -a "$WEBROOT/install.sh" "$BACKUP_DIR/install.sh.bak-$ts" 2>/dev/null || true
cp -a "$WEBROOT/install.ps1" "$BACKUP_DIR/install.ps1.bak-$ts" 2>/dev/null || true
for f in /tmp/bn/privatae-bastion-*; do b=$(basename "$f"); mv "$f" "$WEBROOT/dl/$b"; chmod 755 "$WEBROOT/dl/$b"; done
# The signature lands before the manifest it signs. A bastion that reads
# the new manifest in between asks for its signature by the manifest's
# digest and finds it already there.
for f in /tmp/bn/version.json.*.sig; do b=$(basename "$f"); mv "$f" "$WEBROOT/$b"; chmod 644 "$WEBROOT/$b"; done
mv /tmp/bn/version.json "$WEBROOT/version.json"; chmod 644 "$WEBROOT/version.json"
mv /tmp/bn/install.sh "$WEBROOT/install.sh"; chmod 644 "$WEBROOT/install.sh"
mv /tmp/bn/install.ps1 "$WEBROOT/install.ps1"; chmod 644 "$WEBROOT/install.ps1"
mv /tmp/bn/LICENSE "$WEBROOT/LICENSE.txt"; chmod 644 "$WEBROOT/LICENSE.txt"
rm -rf /tmp/bn
REMOTE
done
# Verify each edge independently: the bytes on ITS disk must match the digests in
# the manifest IT serves. This is the one failure the manifest cannot catch on its
# own -- a publish that reported success while leaving an edge half-written looks
# identical to a good one until a customer's install refuses.
for E in $EDGES; do
printf ' %s: ' "$E"
ssh -i "$KEY" "$E" 'cd '"$WEBROOT"' && python3 - <<VERIFY
import hashlib, json, os, sys
m = json.load(open("version.json"))
bad = []
for k, v in m["binaries"].items():
p = os.path.join("dl", "privatae-bastion-" + k)
if not os.path.exists(p):
bad.append(k + " MISSING"); continue
if hashlib.sha256(open(p, "rb").read()).hexdigest() != v["sha256"]:
bad.append(k + " DIGEST-MISMATCH")
# The signature of this manifest, under its digest name.
sig = "version.json.%s.sig" % hashlib.sha256(open("version.json", "rb").read()).hexdigest()[:16]
if not os.path.exists(sig):
bad.append(sig + " MISSING")
# LICENSE.txt is not in the manifest, so its expected digest is the one this
# publish computed from the repo and passed in. Same failure shape as a binary:
# an edge serving the wrong license text fails the publish.
if not os.path.exists("LICENSE.txt"):
bad.append("LICENSE.txt MISSING")
elif hashlib.sha256(open("LICENSE.txt", "rb").read()).hexdigest() != "'"$LIC_SHA"'":
bad.append("LICENSE.txt DIGEST-MISMATCH")
print(("VERIFY FAILED: " + "; ".join(bad)) if bad else
"ok - %d binaries + LICENSE.txt match the manifest (v%s)" % (len(m["binaries"]), m["version"]))
sys.exit(1 if bad else 0)
VERIFY'
done
# OUTSIDE-IN VERIFY — the check the per-edge loop above cannot make.
#
# That loop reads each edge's own disk over ssh, so it proves the bytes LANDED.
# It once proved exactly that and reported success while Cloudflare
# served the previous windows-amd64.exe to the actual internet. A privileged
# vantage cannot see a cache that sits in front of it; only a plain public fetch
# can. So fetch the manifest and every binary the way a customer's machine does
# and compare against the digests — no ssh, no mesh, no key.
echo "--- verifying from the public internet (through the cache) ---"
python3 - "$VER" "$LIC_SHA" <<'OUTSIDE'
import hashlib, json, sys, urllib.request
ver, lic_sha = sys.argv[1], sys.argv[2]
def get(u):
req = urllib.request.Request(u, headers={"User-Agent": "publish-verify"})
with urllib.request.urlopen(req, timeout=180) as r:
return r.read(), r.headers
m, _ = get("https://privatae.ai/bastion/version.json")
m = json.loads(m)
if m["version"] != ver:
print(" VERIFY FAILED: the public manifest says %s, we published %s" % (m["version"], ver))
sys.exit(1)
bad = []
for k, v in sorted(m["binaries"].items()):
body, hdrs = get(v["url"])
got = hashlib.sha256(body).hexdigest()
cache = hdrs.get("cf-cache-status", "-")
if got == v["sha256"]:
print(" %-20s ok (cache %s)" % (k, cache))
else:
bad.append(k)
print(" %-20s STALE — served %s, manifest %s (cache %s, age %s)"
% (k, got[:16], v["sha256"][:16], cache, hdrs.get("age", "-")))
# LICENSE.txt gets the same treatment, at BOTH URLs. ?v= defeats a warm cache the
# way it does for the binaries — but the installers print the BARE url, and that is
# the one a person actually opens, so checking only the cache-busted key would pass
# while a warm edge served an old license to every user. The binaries have no such
# gap: update.go downloads the ?v= url straight out of the manifest, so there the
# key that is verified IS the key that is fetched. LICENSE.txt is not in the
# manifest, so both URLs are compared against the digest of the LICENSE in the tree
# being published, and a non-200 raises out of get() into the same failure.
for label, lic_url in (("LICENSE.txt?v", f"https://privatae.ai/bastion/LICENSE.txt?v={ver}"),
("LICENSE.txt bare", "https://privatae.ai/bastion/LICENSE.txt")):
try:
body, hdrs = get(lic_url)
except Exception as e:
bad.append(label)
print(" %-20s UNREACHABLE — %s" % (label, e))
continue
got = hashlib.sha256(body).hexdigest()
cache = hdrs.get("cf-cache-status", "-")
if got == lic_sha:
print(" %-20s ok (cache %s)" % (label, cache))
continue
bad.append(label)
if label == "LICENSE.txt bare":
# Name the remedy: this one is a cache in front of a correct origin, and the
# ?v= line above will usually have passed, so the fix is a purge and re-run
# rather than a rebuild.
print(" LICENSE.txt STALE at the bare URL (edge cache) — purge https://privatae.ai/bastion/LICENSE.txt and re-run")
print(" %-20s STALE — served %s, repo %s (cache %s, age %s)"
% (label, got[:16], lic_sha[:16], cache, hdrs.get("age", "-")))
if bad:
print("\n VERIFY FAILED: %d object/objects are stale or missing at the public URL: %s" % (len(bad), ", ".join(bad)))
print(" The edges hold the right bytes; something in front of them does not.")
sys.exit(1)
print(" all %d binaries + LICENSE.txt (both URLs) match as the internet sees them" % len(m["binaries"]))
OUTSIDE
# The signature, fetched the way a bastion fetches it (release_sign.go
# releaseSigURL: version.json.<first 8 bytes of the manifest's sha256>.sig),
# and checked against the manifest the internet is served.
SIGCHECK="$(mktemp -d)"; trap 'rm -rf "$SIGCHECK"' EXIT
curl -fsS --max-time 30 "https://privatae.ai/bastion/version.json" -o "$SIGCHECK/m"
H="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest()[:16])' "$SIGCHECK/m")"
curl -fsS --max-time 30 "https://privatae.ai/bastion/version.json.$H.sig" -o "$SIGCHECK/s" \
|| { echo " VERIFY FAILED: version.json.$H.sig is not served publicly" >&2; exit 1; }
if "$HERE/deploy/release-verify.sh" bastion "$SIGCHECK/m" "$SIGCHECK/s" ${CUR_FROM:+"$CUR_FROM"} >/dev/null; then
echo " version.json.$H.sig ok (verifies against the public manifest)"
else
echo " VERIFY FAILED: the public version.json.$H.sig does not verify against the public manifest" >&2
exit 1
fi
echo "published bastion v$VER to:"
for E in $EDGES; do echo " $E"; done
echo " https://privatae.ai/bastion/ (manifest: /bastion/version.json)"
+10
View File
@@ -0,0 +1,10 @@
module git.privatae.ai/privatae/privatae-bastion/deploy/release-sign
go 1.25.0
require (
golang.org/x/crypto v0.53.0
golang.org/x/term v0.45.0
)
require golang.org/x/sys v0.47.0 // indirect
+6
View File
@@ -0,0 +1,6 @@
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
+387
View File
@@ -0,0 +1,387 @@
// release-sign signs bastion, bastion-agent and C-Series Cloud release
// manifests, and the C-Series App Store catalog, with the offline release key,
// on the computer that holds it.
//
// release-sign new [--out <file>] make a key (asks for a passphrase)
// release-sign pub [--key <file>] print the key's public half
// release-sign sign <kind> <version> <sha256> [--key <file>]
// release-sign verify <kind> <manifest> <sig-file> <public-key>...
// release-sign pinned <release_sign.go> print the keys a bastion source pins
//
// <kind> is "bastion", "bastion-agent", "cseries-cloud" or "cseries-store".
// cseries-cloud is the manifest a C-Series Cloud machine's updater reads before
// it moves to new images or a new overlay, and cseries-store is the App Store
// catalog a C-Series Host reads before it lists or installs an app. Both are
// signed with the same key so that control of the code host alone cannot roll
// those machines or change what a Host installs. A release waits at the publish step
// for the line `sign` prints; the private key never leaves this computer and
// is stored encrypted under the passphrase, so a copy of the file alone does
// not sign anything.
//
// What is signed is a short statement, not the manifest itself, so the person
// signing can read it:
//
// privatae-release-signature-v1
// <kind>
// <version>
// <sha256 of the manifest's exact bytes, hex>
//
// The bastion rebuilds that statement from the manifest it downloaded
// (client-go/release_sign.go) and checks the signature against it.
package main
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/term"
)
// Statement is the exact text a release signature covers. The bastion builds
// the same text in client-go/release_sign.go; the two must never differ.
func Statement(kind, version, sha256hex string) []byte {
return []byte("privatae-release-signature-v1\n" + kind + "\n" + version + "\n" + sha256hex + "\n")
}
var (
kinds = map[string]bool{"bastion": true, "bastion-agent": true, "cseries-cloud": true, "cseries-store": true}
versionRe = regexp.MustCompile(`^[0-9]+(\.[0-9]+)*$`)
shaRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
)
// keyFile is the encrypted private key on disk. The 32-byte ed25519 seed is
// sealed with XChaCha20-Poly1305 under a key derived from the passphrase with
// argon2id.
type keyFile struct {
V int `json:"v"`
Pub string `json:"pub"`
Salt string `json:"salt"`
Time uint32 `json:"argon2_time"`
Memory uint32 `json:"argon2_memory_kib"`
Thread uint8 `json:"argon2_threads"`
Nonce string `json:"nonce"`
Sealed string `json:"sealed"`
}
// argon2id parameters: OWASP's first recommended setting is 19 MiB, t=2. This
// key is opened once per release on one person's computer, so it can afford
// far more; 256 MiB, t=4 takes about a second on a current laptop.
const (
kdfTime = 4
kdfMemory = 256 * 1024
kdfThreads = 4
)
// defaultKeyPath keeps the key in the LOCAL profile. On Windows that is
// %LocalAppData%, not the Roaming %AppData% os.UserConfigDir returns: a
// roaming profile is copied to the domain's servers, and this key is meant
// to stay on this one computer.
func defaultKeyPath() string {
dir := os.Getenv("LOCALAPPDATA")
if dir == "" {
var err error
if dir, err = os.UserConfigDir(); err != nil {
dir = "."
}
}
return filepath.Join(dir, "privatae-release", "release-key.json")
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "release-sign:", err)
os.Exit(1)
}
}
func usage() error {
return errors.New(`usage:
release-sign new [--out <file>]
release-sign pub [--key <file>]
release-sign sign <kind> <version> <sha256> [--key <file>]
release-sign verify <kind> <manifest> <sig-file> <public-key>...
release-sign pinned <release_sign.go>`)
}
// flagValue removes "--name value" from args and returns the value.
func flagValue(args []string, name string) ([]string, string) {
for i := 0; i < len(args)-1; i++ {
if args[i] == name {
return append(append([]string{}, args[:i]...), args[i+2:]...), args[i+1]
}
}
return args, ""
}
func run(args []string) error {
if len(args) == 0 {
return usage()
}
cmd, rest := args[0], args[1:]
switch cmd {
case "new":
rest, out := flagValue(rest, "--out")
if len(rest) != 0 {
return usage()
}
if out == "" {
out = defaultKeyPath()
}
return cmdNew(out)
case "pub":
rest, path := flagValue(rest, "--key")
if len(rest) != 0 {
return usage()
}
if path == "" {
path = defaultKeyPath()
}
kf, err := readKeyFile(path)
if err != nil {
return err
}
fmt.Println(kf.Pub)
return nil
case "sign":
rest, path := flagValue(rest, "--key")
if len(rest) != 3 {
return usage()
}
if path == "" {
path = defaultKeyPath()
}
return cmdSign(path, rest[0], rest[1], strings.ToLower(rest[2]))
case "pinned":
if len(rest) != 1 {
return usage()
}
keys, err := PinnedKeys(rest[0])
if err != nil {
return err
}
fmt.Println(strings.Join(keys, " "))
return nil
case "verify":
if len(rest) < 4 {
return usage()
}
return cmdVerify(rest[0], rest[1], rest[2], rest[3:])
}
return usage()
}
func readPassphrase(prompt string) ([]byte, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil, errors.New("run this in PowerShell, Command Prompt or Windows Terminal: it asks for the passphrase and cannot read one piped in")
}
fmt.Fprint(os.Stderr, prompt)
p, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
return p, err
}
func deriveKey(pass, salt []byte, t, m uint32, p uint8) []byte {
return argon2.IDKey(pass, salt, t, m, p, chacha20poly1305.KeySize)
}
func cmdNew(out string) error {
if _, err := os.Stat(out); err == nil {
return fmt.Errorf("%s already exists; a release key is never overwritten", out)
}
pass, err := readPassphrase("New passphrase for the release key: ")
if err != nil {
return err
}
if len(pass) < 12 {
return errors.New("use a passphrase of at least 12 characters")
}
again, err := readPassphrase("Same passphrase again: ")
if err != nil {
return err
}
if string(again) != string(pass) {
return errors.New("the two passphrases differ; nothing was written")
}
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return err
}
kf, err := seal(priv.Seed(), pub, pass)
if err != nil {
return err
}
data, _ := json.MarshalIndent(kf, "", " ")
if err := os.MkdirAll(filepath.Dir(out), 0o700); err != nil {
return err
}
f, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
if _, err := f.Write(append(data, '\n')); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Key written to %s\nPublic key (this is what gets pinned in the bastion):\n", out)
fmt.Println(kf.Pub)
return nil
}
func seal(seed []byte, pub ed25519.PublicKey, pass []byte) (*keyFile, error) {
salt := make([]byte, 16)
nonce := make([]byte, chacha20poly1305.NonceSizeX)
if _, err := rand.Read(salt); err != nil {
return nil, err
}
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
aead, err := chacha20poly1305.NewX(deriveKey(pass, salt, kdfTime, kdfMemory, kdfThreads))
if err != nil {
return nil, err
}
pubB64 := base64.StdEncoding.EncodeToString(pub)
return &keyFile{
V: 1, Pub: pubB64,
Salt: base64.StdEncoding.EncodeToString(salt), Time: kdfTime, Memory: kdfMemory, Thread: kdfThreads,
Nonce: base64.StdEncoding.EncodeToString(nonce),
Sealed: base64.StdEncoding.EncodeToString(aead.Seal(nil, nonce, seed, []byte(pubB64))),
}, nil
}
func readKeyFile(path string) (*keyFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var kf keyFile
if err := json.Unmarshal(data, &kf); err != nil || kf.V != 1 {
return nil, fmt.Errorf("%s is not a release key file", path)
}
return &kf, nil
}
func open(kf *keyFile, pass []byte) (ed25519.PrivateKey, error) {
// The settings come from the file. Out-of-range ones would make argon2
// panic (time or threads of 0) or try to allocate without bound, so a
// damaged or edited file is refused instead.
if kf.Time < 1 || kf.Time > 64 || kf.Thread < 1 || kf.Thread > 64 ||
kf.Memory < 64*1024 || kf.Memory > 4*1024*1024 {
return nil, errors.New("key file is damaged: its key-derivation settings are out of range")
}
salt, e1 := base64.StdEncoding.DecodeString(kf.Salt)
nonce, e2 := base64.StdEncoding.DecodeString(kf.Nonce)
sealed, e3 := base64.StdEncoding.DecodeString(kf.Sealed)
if e1 != nil || e2 != nil || e3 != nil {
return nil, errors.New("key file is damaged")
}
aead, err := chacha20poly1305.NewX(deriveKey(pass, salt, kf.Time, kf.Memory, kf.Thread))
if err != nil {
return nil, err
}
seed, err := aead.Open(nil, nonce, sealed, []byte(kf.Pub))
if err != nil {
return nil, errors.New("wrong passphrase, or the key file was changed")
}
priv := ed25519.NewKeyFromSeed(seed)
if base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey)) != kf.Pub {
return nil, errors.New("key file is damaged")
}
return priv, nil
}
func checkStatement(kind, version, sha string) error {
if !kinds[kind] {
return fmt.Errorf("kind must be bastion, bastion-agent, cseries-cloud or cseries-store, not %q", kind)
}
if !versionRe.MatchString(version) {
return fmt.Errorf("%q is not a release version", version)
}
if !shaRe.MatchString(sha) {
return errors.New("the sha256 must be 64 hex characters")
}
return nil
}
func cmdSign(path, kind, version, sha string) error {
if err := checkStatement(kind, version, sha); err != nil {
return err
}
kf, err := readKeyFile(path)
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Signing the %s %s release (manifest sha256 %s…%s).\n", kind, version, sha[:8], sha[56:])
pass, err := readPassphrase("Passphrase: ")
if err != nil {
return err
}
priv, err := open(kf, pass)
if err != nil {
return err
}
fmt.Fprintln(os.Stderr, "Paste this line back into the release:")
fmt.Println(base64.StdEncoding.EncodeToString(ed25519.Sign(priv, Statement(kind, version, sha))))
return nil
}
// cmdVerify checks a signature over a manifest file the way the bastion does,
// against any of the given public keys. The publish and verify scripts use it.
func cmdVerify(kind, manifestPath, sigPath string, pubs []string) error {
body, err := os.ReadFile(manifestPath)
if err != nil {
return err
}
sig, err := os.ReadFile(sigPath)
if err != nil {
return err
}
pub, err := VerifyManifest(kind, body, sig, pubs)
if err != nil {
return err
}
fmt.Println(pub)
return nil
}
// VerifyManifest mirrors client-go/release_sign.go verifyReleaseSignature and
// returns the key that verified.
func VerifyManifest(kind string, body, sig []byte, pubs []string) (string, error) {
var m struct {
Version string `json:"version"`
}
if err := json.Unmarshal(body, &m); err != nil {
return "", errors.New("manifest is not JSON")
}
sum := sha256.Sum256(body)
if err := checkStatement(kind, m.Version, hex.EncodeToString(sum[:])); err != nil {
return "", err
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig)))
if err != nil || len(raw) != ed25519.SignatureSize {
return "", errors.New("signature is malformed")
}
msg := Statement(kind, m.Version, hex.EncodeToString(sum[:]))
for _, k := range pubs {
pub, err := base64.StdEncoding.DecodeString(k)
if err == nil && len(pub) == ed25519.PublicKeySize && ed25519.Verify(pub, msg, raw) {
return k, nil
}
}
return "", errors.New("signature does not verify against any of the given keys")
}
+199
View File
@@ -0,0 +1,199 @@
package main
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"os"
"strings"
"testing"
)
// The shared vector. client-go/release_sign_test.go holds the same three
// values; if the statement format changes on one side only, one of the two
// tests fails.
var (
vectorSeed = strings.Repeat("\x01", ed25519.SeedSize)
vectorManifest = []byte(`{"version":"1.2.3","binaries":{}}`)
)
func vectorKey() ed25519.PrivateKey { return ed25519.NewKeyFromSeed([]byte(vectorSeed)) }
func TestSharedVector(t *testing.T) {
priv := vectorKey()
sum := sha256.Sum256(vectorManifest)
sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, Statement("bastion", "1.2.3", hex.EncodeToString(sum[:]))))
if sig != sharedVectorSig {
t.Fatalf("the statement format changed: signature is now %s", sig)
}
pub := base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey))
if got, err := VerifyManifest("bastion", vectorManifest, []byte(sig), []string{pub}); err != nil || got != pub {
t.Fatalf("verify: %v", err)
}
}
const sharedVectorSig = "tr5h3oXjBDmwvwQiZSriW4T6Y6oBFQDymfofLEGXMdTJSXfoQAKN+ajhCs89FO9vdCEZH3Y8mYzYXPZ7RzZVDw=="
// The C-Series Cloud vector. The box's updater (vm/cloud in the C-Series
// tree) checks the same seed, manifest and signature, so a change to how a
// cseries-cloud statement is built fails on both sides.
var cloudVectorManifest = []byte(`{"version":"1.2.3","images":{}}`)
const cloudVectorSig = "69+ug9q0ndTtLijHaOlpD8aFiQcO9/TRvmB+4jLNNrDSbMzx1mvDlOXzOgxDvQLTWa8LZmqxHABLF75kxYl4Bw=="
func TestCloudKindSignsAndVerifiesOnlyAsItself(t *testing.T) {
priv := vectorKey()
pub := base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey))
sum := sha256.Sum256(cloudVectorManifest)
if err := checkStatement("cseries-cloud", "1.2.3", hex.EncodeToString(sum[:])); err != nil {
t.Fatalf("a cseries-cloud statement must be signable: %v", err)
}
sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, Statement("cseries-cloud", "1.2.3", hex.EncodeToString(sum[:]))))
if sig != cloudVectorSig {
t.Fatalf("the cseries-cloud statement changed: signature is now %s", sig)
}
if got, err := VerifyManifest("cseries-cloud", cloudVectorManifest, []byte(sig), []string{pub}); err != nil || got != pub {
t.Fatalf("verify: %v", err)
}
for _, other := range []string{"bastion", "bastion-agent"} {
if _, err := VerifyManifest(other, cloudVectorManifest, []byte(sig), []string{pub}); err == nil {
t.Fatalf("a cseries-cloud signature must not pass as %s", other)
}
}
bastionSig := []byte(base64.StdEncoding.EncodeToString(ed25519.Sign(priv, Statement("bastion", "1.2.3", hex.EncodeToString(sum[:])))))
if _, err := VerifyManifest("cseries-cloud", cloudVectorManifest, bastionSig, []string{pub}); err == nil {
t.Fatal("a bastion signature must not pass as a cseries-cloud signature")
}
}
// The C-Series App Store catalog. The C-Series Host's public store reader
// builds the same statement and asserts these exact bytes for it, so a change
// to the text on either side fails a test.
var storeVectorCatalog = []byte(`{"version":"1.2.3","apps":[]}`)
const storeVectorSig = "+wAepICIvr4vLfhhbbONLJjP2KnoEysh7hMZjZtgRbqrxVcUiHKSWbzP3AoOKemvH7oHjRMkpqQAX/GVlmjWDg=="
func TestStoreKindSignsAndVerifiesOnlyAsItself(t *testing.T) {
want := "privatae-release-signature-v1\ncseries-store\n1.2.3\n" + strings.Repeat("ab", 32) + "\n"
if got := string(Statement("cseries-store", "1.2.3", strings.Repeat("ab", 32))); got != want {
t.Fatalf("the cseries-store statement is %q, the C-Series reader builds %q", got, want)
}
priv := vectorKey()
pub := base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey))
sum := sha256.Sum256(storeVectorCatalog)
if err := checkStatement("cseries-store", "1.2.3", hex.EncodeToString(sum[:])); err != nil {
t.Fatalf("a cseries-store statement must be signable: %v", err)
}
sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, Statement("cseries-store", "1.2.3", hex.EncodeToString(sum[:]))))
if sig != storeVectorSig {
t.Fatalf("the cseries-store statement changed: signature is now %s", sig)
}
if got, err := VerifyManifest("cseries-store", storeVectorCatalog, []byte(sig), []string{pub}); err != nil || got != pub {
t.Fatalf("verify: %v", err)
}
for _, other := range []string{"bastion", "bastion-agent", "cseries-cloud"} {
if _, err := VerifyManifest(other, storeVectorCatalog, []byte(sig), []string{pub}); err == nil {
t.Fatalf("a cseries-store signature must not pass as %s", other)
}
otherSig := []byte(base64.StdEncoding.EncodeToString(ed25519.Sign(priv, Statement(other, "1.2.3", hex.EncodeToString(sum[:])))))
if _, err := VerifyManifest("cseries-store", storeVectorCatalog, otherSig, []string{pub}); err == nil {
t.Fatalf("a %s signature must not pass as a cseries-store signature", other)
}
}
}
func TestSealAndOpen(t *testing.T) {
priv := vectorKey()
kf, err := seal(priv.Seed(), priv.Public().(ed25519.PublicKey), []byte("correct horse battery"))
if err != nil {
t.Fatal(err)
}
got, err := open(kf, []byte("correct horse battery"))
if err != nil || !got.Equal(priv) {
t.Fatalf("the right passphrase must open the key: %v", err)
}
if _, err := open(kf, []byte("wrong horse battery")); err == nil {
t.Fatal("a wrong passphrase must not open the key")
}
kf.Pub = base64.StdEncoding.EncodeToString(make([]byte, 32))
if _, err := open(kf, []byte("correct horse battery")); err == nil {
t.Fatal("a key file whose public key was swapped must not open")
}
}
func TestVerifyRefusesTheWrongKindOrAChangedManifest(t *testing.T) {
priv := vectorKey()
pub := base64.StdEncoding.EncodeToString(priv.Public().(ed25519.PublicKey))
sum := sha256.Sum256(vectorManifest)
sig := []byte(base64.StdEncoding.EncodeToString(ed25519.Sign(priv, Statement("bastion", "1.2.3", hex.EncodeToString(sum[:])))))
if _, err := VerifyManifest("bastion-agent", vectorManifest, sig, []string{pub}); err == nil {
t.Fatal("a bastion signature must not pass as an agent signature")
}
if _, err := VerifyManifest("bastion", []byte(`{"version":"1.2.4","binaries":{}}`), sig, []string{pub}); err == nil {
t.Fatal("a changed manifest must not verify")
}
}
func TestSignRefusesAnythingThatIsNotAStatement(t *testing.T) {
good := strings.Repeat("a", 64)
for _, c := range [][3]string{
{"other", "1.2.3", good},
{"bastion", "1.2.3\nextra", good},
{"bastion", "1.2.3", "abc"},
{"bastion", "1.2.3", strings.Repeat("A", 64)},
} {
if err := checkStatement(c[0], c[1], c[2]); err == nil {
t.Fatalf("%q must be refused", c)
}
}
if err := checkStatement("bastion-agent", "0.1.12", good); err != nil {
t.Fatal(err)
}
}
func TestPinnedKeysReadsWhatTheCompilerSees(t *testing.T) {
dir := t.TempDir()
write := func(src string) string {
p := dir + "/release_sign.go"
if err := os.WriteFile(p, []byte(src), 0o600); err != nil {
t.Fatal(err)
}
return p
}
k1 := strings.Repeat("A", 43) + "="
k2 := strings.Repeat("B", 43) + "="
for name, c := range map[string]struct {
src string
want int
}{
"empty": {"package main\nvar productionReleaseKeys = []string{}\nfunc x() {\n\t_ = \"" + k1 + "\"\n}\n", 0},
"one line": {"package main\nvar productionReleaseKeys = []string{\"" + k1 + "\", \"" + k2 + "\"}\n", 2},
"commented": {"package main\nvar productionReleaseKeys = []string{\n\t// \"" + k2 + "\",\n\t\"" + k1 + "\",\n}\n", 1},
"var block": {"package main\nvar (\n\tother = 1\n\tproductionReleaseKeys = []string{\n\t\t\"" + k1 + "\",\n\t\t\"" + k2 + "\",\n\t}\n)\n", 2},
} {
keys, err := PinnedKeys(write(c.src))
if err != nil || len(keys) != c.want {
t.Fatalf("%s: got %v %v, want %d keys", name, keys, err, c.want)
}
}
}
func TestOpenRefusesOutOfRangeSettings(t *testing.T) {
priv := vectorKey()
kf, err := seal(priv.Seed(), priv.Public().(ed25519.PublicKey), []byte("correct horse battery"))
if err != nil {
t.Fatal(err)
}
for _, mutate := range []func(*keyFile){
func(k *keyFile) { k.Time = 0 },
func(k *keyFile) { k.Thread = 0 },
func(k *keyFile) { k.Memory = 1 << 31 },
} {
bad := *kf
mutate(&bad)
if _, err := open(&bad, []byte("correct horse battery")); err == nil {
t.Fatal("out-of-range settings must be refused, not run")
}
}
}
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"strconv"
)
// PinnedKeys reads the productionReleaseKeys list out of a bastion source
// file (client-go/release_sign.go) by parsing it as Go, so the publish and
// verify scripts see exactly the keys the compiler does: comments are not
// keys, and the list's layout does not matter.
func PinnedKeys(path string) ([]string, error) {
f, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.SkipObjectResolution)
if err != nil {
return nil, err
}
var found *ast.CompositeLit
for _, d := range f.Decls {
g, ok := d.(*ast.GenDecl)
if !ok || g.Tok != token.VAR {
continue
}
for _, sp := range g.Specs {
vs, ok := sp.(*ast.ValueSpec)
if !ok {
continue
}
for i, n := range vs.Names {
if n.Name != "productionReleaseKeys" || i >= len(vs.Values) {
continue
}
lit, ok := vs.Values[i].(*ast.CompositeLit)
if !ok {
return nil, errors.New("productionReleaseKeys is not a list literal")
}
found = lit
}
}
}
if found == nil {
return nil, fmt.Errorf("%s declares no productionReleaseKeys", path)
}
var keys []string
for _, e := range found.Elts {
b, ok := e.(*ast.BasicLit)
if !ok || b.Kind != token.STRING {
return nil, errors.New("productionReleaseKeys holds something other than string literals")
}
k, err := strconv.Unquote(b.Value)
if err != nil {
return nil, err
}
keys = append(keys, k)
}
return keys, nil
}
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Check a release manifest's signature the way a bastion does.
#
# deploy/release-verify.sh <bastion|bastion-agent> <manifest> <sig-file> [<rev>...]
#
# The signature must verify against the keys this tree pins
# (productionReleaseKeys in client-go/release_sign.go), and this tree must pin
# at least two (the primary and the offline spare). Each <rev> names another
# commit of this repository whose pinned keys it must ALSO verify against:
# publish.sh passes the commit the fleet runs today, because those are the
# bastions that will read this manifest, and they check it with their own
# keys, not with the ones this release introduces.
#
# The keys are read by parsing the Go source (release-sign pinned), so the
# script sees what the compiler sees. The check runs in the same pinned Go
# image deploy/build.sh uses. Prints the key that verified against this tree.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
KIND="${1:?kind}"; MAN="${2:?manifest}"; SIG="${3:?signature file}"; shift 3
GO_IMAGE="$(sed -n 's/^GO_IMAGE="\(.*\)"$/\1/p' "$HERE/deploy/build.sh")"
MODCACHE="${GOMODCACHE_DIR:-$HOME/go/pkg/mod}"
mkdir -p "$MODCACHE"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
cp "$MAN" "$WORK/manifest"; cp "$SIG" "$WORK/sig"
cp "$HERE/client-go/release_sign.go" "$WORK/keys-0.go"
n=0
for rev in "$@"; do
n=$((n + 1))
# A commit from before signing pins nothing and its bastions check nothing,
# so there is no key set to hold this signature to.
git -C "$HERE" show "$rev:client-go/release_sign.go" > "$WORK/keys-$n.go" 2>/dev/null \
|| rm -f "$WORK/keys-$n.go"
done
docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp -e GOCACHE=/tmp/gocache \
-v "$HERE/deploy/release-sign":/src:ro -v "$WORK":/in:ro -v "$MODCACHE":/go/pkg/mod \
-w /src -e GOTOOLCHAIN=local -e GOFLAGS="-mod=readonly -buildvcs=false" -e CGO_ENABLED=0 \
-e KIND="$KIND" "$GO_IMAGE" sh -ec '
go build -o /tmp/release-sign .
set -- $(/tmp/release-sign pinned /in/keys-0.go)
[ "$#" -ge 2 ] || { echo "this tree pins $# release key(s); a release needs the primary and the spare" >&2; exit 1; }
signed_by=$(/tmp/release-sign verify "$KIND" /in/manifest /in/sig "$@")
for f in /in/keys-*.go; do
[ "$f" = /in/keys-0.go ] && continue
set -- $(/tmp/release-sign pinned "$f")
[ "$#" -gt 0 ] || continue
/tmp/release-sign verify "$KIND" /in/manifest /in/sig "$@" >/dev/null \
|| { echo "the signature does not verify against the keys pinned by the release the fleet runs today" >&2; exit 1; }
done
echo "$signed_by"'
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# Rebuild the published bastion from source and check it is what was published.
#
# deploy/verify-release.sh
#
# Needs git, docker, curl and python3. Run it from a clone of this
# repository. It does not need any key or account of ours.
#
# It fetches the public manifest, checks its signature against the release keys
# pinned in client-go/release_sign.go, checks out the release's source in a
# separate worktree, rebuilds with deploy/build.sh exactly as publish.sh does,
# and compares every binary's sha256 with the manifest. If they all match, the
# binaries we serve are the ones this source produces, and the signature says
# we are the ones who published them.
#
# Which source: the manifest's built_from names the commit in the repository
# the release was built in. The public repository carries each release as a
# single commit of the same tree, tagged v<version>, so built_from is not a
# commit there. The script uses built_from when this checkout has it, and
# otherwise the tag v<version>, which must be an annotated tag whose message
# has the line "built from <built_from>" exactly as the manifest writes it.
# Either way, the digests show this source builds the published binaries.
# Files the build does not read could differ without changing the bytes.
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
BASE="${MANIFEST_BASE:-https://privatae.ai/bastion}"
WORK="$(mktemp -d)"
trap 'git -C "$HERE" worktree remove --force "$WORK/src" >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
curl -fsS --max-time 30 "$BASE/version.json" -o "$WORK/version.json"
H="$(python3 -c 'import hashlib,sys;print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest()[:16])' "$WORK/version.json")"
curl -fsS --max-time 30 "$BASE/version.json.$H.sig" -o "$WORK/version.json.sig"
read -r VER FROM < <(python3 -c 'import json,sys;m=json.load(open(sys.argv[1]));print(m["version"],m["built_from"])' "$WORK/version.json")
echo "published: v$VER built from $FROM"
# The signature, against the keys YOUR checkout pins (the starting point you
# chose to trust) and against the keys the published commit pins.
FETCH_FAILED=0
git -C "$HERE" fetch -q --tags origin 2>"$WORK/fetch.err" || FETCH_FAILED=1
if [ "$FETCH_FAILED" -eq 1 ]; then
echo "fetch: could not fetch tags from origin (git exited non-zero)" >&2
sed 's/^/ /' "$WORK/fetch.err" >&2
fi
TAG="v$VER"
if git -C "$HERE" cat-file -e "$FROM^{commit}" 2>/dev/null; then
SRC="$(git -C "$HERE" rev-parse "$FROM^{commit}")"
echo "source: commit $FROM (built_from, present in this checkout)"
elif git -C "$HERE" rev-parse -q --verify "refs/tags/$TAG^{commit}" >/dev/null; then
# A release tag is annotated and records the built_from it was published
# from, in the same form the manifest uses. Without that record there is
# nothing tying the tag to this release.
if [ "$(git -C "$HERE" cat-file -t "refs/tags/$TAG")" != tag ] \
|| ! git -C "$HERE" tag -l --format='%(contents)' "$TAG" | grep -qxF "built from $FROM"; then
echo "source: tag $TAG does not carry the release record (an annotated tag with the line 'built from $FROM'); nothing was built" >&2
exit 1
fi
SRC="$(git -C "$HERE" rev-parse "refs/tags/$TAG^{commit}")"
echo "source: tag $TAG at commit $SRC (built_from $FROM is not in this checkout)"
elif [ "$FETCH_FAILED" -eq 1 ]; then
echo "source: neither built_from $FROM nor tag $TAG is in this checkout, and tags could not be fetched; nothing was built" >&2
exit 1
else
echo "source: neither built_from $FROM nor tag $TAG is in this checkout or on origin" >&2
echo " the release may not be tagged yet; nothing was built" >&2
exit 1
fi
git -C "$HERE" worktree add -q --detach "$WORK/src" "$SRC"
SIGNED_BY="$("$HERE/deploy/release-verify.sh" bastion "$WORK/version.json" "$WORK/version.json.sig" "$SRC")" \
|| { echo "signature: FAILED against the keys pinned in this checkout or at $SRC" >&2; exit 1; }
echo "signature: ok ($SIGNED_BY)"
# The rebuild, with the build script from that commit.
"$WORK/src/deploy/build.sh" "$VER"
python3 - "$WORK/version.json" "$WORK/src/client-go/dist" <<'PY'
import hashlib, json, os, sys
m = json.load(open(sys.argv[1]))
dist = sys.argv[2]
bad = 0
for k, v in sorted(m["binaries"].items()):
p = os.path.join(dist, "privatae-bastion-" + k)
got = hashlib.sha256(open(p, "rb").read()).hexdigest() if os.path.exists(p) else "missing"
same = got == v["sha256"]
bad += not same
print(" %-20s %s" % (k, "ok" if same else "DIFFERS (rebuilt %s, published %s)" % (got[:16], v["sha256"][:16])))
print("rebuild: every binary matches the published digest" if not bad else "rebuild: %d binaries differ" % bad)
sys.exit(1 if bad else 0)
PY