bastion 0.53.2
This commit is contained in:
@@ -0,0 +1 @@
|
||||
0.53.2
|
||||
Executable
+47
@@ -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'
|
||||
Executable
+70
@@ -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
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Executable
+337
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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=
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+50
@@ -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"'
|
||||
Executable
+87
@@ -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
|
||||
Reference in New Issue
Block a user