bastion 0.61.0
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ed25519SPKIPrefix is the DER that wraps a raw Ed25519 public key in the
|
||||
// SubjectPublicKeyInfo openssl reads (RFC 8410).
|
||||
var ed25519SPKIPrefix = []byte{0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00}
|
||||
|
||||
func installShVar(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := regexp.MustCompile(`(?m)^` + name + `='([^']*)'$`).FindSubmatch(raw)
|
||||
if m == nil {
|
||||
t.Fatalf("install.sh sets no %s", name)
|
||||
}
|
||||
return string(m[1])
|
||||
}
|
||||
|
||||
func spkiToRaw(t *testing.T, b64 string) []byte {
|
||||
t.Helper()
|
||||
der, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(der) != len(ed25519SPKIPrefix)+ed25519.PublicKeySize || !bytes.HasPrefix(der, ed25519SPKIPrefix) {
|
||||
t.Fatalf("not an Ed25519 public key: %q (%v)", b64, err)
|
||||
}
|
||||
return der[len(ed25519SPKIPrefix):]
|
||||
}
|
||||
|
||||
// The installer checks a release with exactly the keys a bastion checks its
|
||||
// updates with: a key rotated in release_sign.go and not here would make
|
||||
// every install refuse, or accept a key the bastions no longer do.
|
||||
func TestInstallShPinsTheReleaseKeysTheBastionChecks(t *testing.T) {
|
||||
keys := strings.Fields(installShVar(t, "RELEASE_KEYS"))
|
||||
if len(keys) != len(productionReleaseKeys) {
|
||||
t.Fatalf("install.sh pins %d release keys, release_sign.go %d", len(keys), len(productionReleaseKeys))
|
||||
}
|
||||
for i, k := range keys {
|
||||
if got := base64.StdEncoding.EncodeToString(spkiToRaw(t, k)); got != productionReleaseKeys[i] {
|
||||
t.Errorf("install.sh key %d is %s, release_sign.go has %s", i, got, productionReleaseKeys[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The probe is what tells "this openssl cannot check" from "the signature is
|
||||
// wrong". A probe that did not verify would make every system read as unable,
|
||||
// and every install skip the signature with a note.
|
||||
func TestInstallShProbeSignatureVerifies(t *testing.T) {
|
||||
pub := spkiToRaw(t, installShVar(t, "SIG_PROBE_KEY"))
|
||||
sig, err := base64.StdEncoding.DecodeString(installShVar(t, "SIG_PROBE_SIG"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(pub), []byte("privatae-installer-probe-v1\n"), sig) {
|
||||
t.Fatal("the probe signature does not verify under the probe key")
|
||||
}
|
||||
}
|
||||
|
||||
// sigHarness is release_signature_check from install.sh, with the release
|
||||
// keys replaced by key and the download host by base, run on manifest.
|
||||
func sigHarness(t *testing.T, pub ed25519.PublicKey, base string, manifest []byte, onlyPATH ...bool) (string, error) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
vars := regexp.MustCompile(`(?ms)^RELEASE_KEYS=.*?^SIG_PROBE_SIG='[^']*'$`).FindString(s)
|
||||
fn := regexp.MustCompile(`(?ms)^release_signature_check\(\) \{.*?^\}$`).FindString(s)
|
||||
if vars == "" || fn == "" {
|
||||
t.Fatal("install.sh's release_signature_check or its keys moved")
|
||||
}
|
||||
if len(onlyPATH) > 0 && onlyPATH[0] {
|
||||
// Only the openssl on PATH: a Mac with Homebrew's openssl@3 would
|
||||
// otherwise find a real one at its fixed path.
|
||||
loop := regexp.MustCompile(`(?m)^ for c in openssl [^;]*; do$`)
|
||||
if !loop.MatchString(fn) {
|
||||
t.Fatal("install.sh's openssl candidates moved")
|
||||
}
|
||||
fn = loop.ReplaceAllString(fn, " for c in openssl; do")
|
||||
}
|
||||
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
|
||||
vars = regexp.MustCompile(`(?m)^RELEASE_KEYS='[^']*'$`).ReplaceAllString(vars,
|
||||
"RELEASE_KEYS='"+base64.StdEncoding.EncodeToString(der)+"'")
|
||||
d := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(d, "man.json"), manifest, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(d, "v"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := "die() { echo \"ERROR: $*\" >&2; exit 1; }\nSHACMD=sha256sum\nsha_of() { $SHACMD \"$1\" | cut -d' ' -f1; }\n" +
|
||||
"base='" + base + "'\n" + vars + "\n" + fn + "\nrelease_signature_check \"$1\" \"$2\"\necho RETURNED\n"
|
||||
sh := filepath.Join(d, "h.sh")
|
||||
if err := os.WriteFile(sh, []byte(script), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := exec.Command("sh", sh, filepath.Join(d, "man.json"), filepath.Join(d, "v")).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func releaseSigFor(priv ed25519.PrivateKey, manifest []byte, version string) (name, b64 string) {
|
||||
sum := sha256.Sum256(manifest)
|
||||
stmt := releaseStatement("bastion", version, hex.EncodeToString(sum[:]))
|
||||
return "/version.json." + hex.EncodeToString(sum[:8]) + ".sig",
|
||||
base64.StdEncoding.EncodeToString(ed25519.Sign(priv, stmt))
|
||||
}
|
||||
|
||||
func TestInstallShRefusesAReleaseWhoseSignatureDoesNotVerify(t *testing.T) {
|
||||
requireTools(t, "sh", "openssl", "sha256sum", "curl")
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
|
||||
name, good := releaseSigFor(priv, manifest, "9.9.9")
|
||||
_, wrongKey := releaseSigFor(other, manifest, "9.9.9")
|
||||
_, wrongVersion := releaseSigFor(priv, manifest, "9.9.8")
|
||||
serve := map[string]string{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
v, ok := serve[r.URL.Path]
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(v))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
// The check needs an openssl that does Ed25519; without one it only
|
||||
// notes it, which TestInstallShSaysWhenItCannotCheckTheSignature covers.
|
||||
// Whether this one can is asked of openssl here, never read off the
|
||||
// installer's note: a probe broken in install.sh would print that note on
|
||||
// every machine, and this test would skip while every install skipped the
|
||||
// signature.
|
||||
// MUTATION: break the probe's pkeyutl flags in install.sh. RESULT: red here
|
||||
// (it skipped before).
|
||||
if !opensslChecksEd25519(t) {
|
||||
skipOrFail(t, "the openssl here does not check Ed25519 signatures")
|
||||
}
|
||||
serve[name] = good
|
||||
out, err := sigHarness(t, pub, srv.URL, manifest)
|
||||
if err != nil || strings.Contains(out, "no openssl here can check") ||
|
||||
!strings.Contains(out, "Release signature verified (version 9.9.9)") || !strings.Contains(out, "RETURNED") {
|
||||
t.Fatalf("a good signature: %v\n%s", err, out)
|
||||
}
|
||||
for what, sig := range map[string]string{"another key's": wrongKey, "another version's": wrongVersion} {
|
||||
serve[name] = sig
|
||||
// MUTATION: return 0 after the key loop. RESULT: red, these install.
|
||||
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || strings.Contains(out, "RETURNED") ||
|
||||
!strings.Contains(out, "does not verify") {
|
||||
t.Errorf("%s signature: %v\n%s", what, err, out)
|
||||
}
|
||||
}
|
||||
serve[name] = "not base64 at all !"
|
||||
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "malformed") {
|
||||
t.Errorf("a malformed signature: %v\n%s", err, out)
|
||||
}
|
||||
delete(serve, name)
|
||||
if out, err := sigHarness(t, pub, srv.URL, manifest); err == nil || !strings.Contains(out, "cannot fetch the release's signature") {
|
||||
t.Errorf("no signature: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// opensslChecksEd25519 says, without install.sh, whether the openssl on PATH
|
||||
// verifies an Ed25519 signature made here, and refuses one over another
|
||||
// message.
|
||||
func opensslChecksEd25519(t *testing.T) bool {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("openssl"); err != nil {
|
||||
return false
|
||||
}
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
der := append(append([]byte{}, ed25519SPKIPrefix...), pub...)
|
||||
d := t.TempDir()
|
||||
files := map[string][]byte{
|
||||
"k.pem": []byte("-----BEGIN PUBLIC KEY-----\n" + base64.StdEncoding.EncodeToString(der) + "\n-----END PUBLIC KEY-----\n"),
|
||||
"m": []byte("capability\n"),
|
||||
"other": []byte("capability?\n"),
|
||||
"s": ed25519.Sign(priv, []byte("capability\n")),
|
||||
}
|
||||
for n, b := range files {
|
||||
if err := os.WriteFile(filepath.Join(d, n), b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
verify := func(msg string) bool {
|
||||
return exec.Command("openssl", "pkeyutl", "-verify", "-pubin", "-inkey", filepath.Join(d, "k.pem"), "-rawin",
|
||||
"-in", filepath.Join(d, msg), "-sigfile", filepath.Join(d, "s")).Run() == nil
|
||||
}
|
||||
return verify("m") && !verify("other")
|
||||
}
|
||||
|
||||
// An openssl that passes every signature is no check: the installer must not
|
||||
// take it for one and print "verified" over a signature no pinned key made.
|
||||
// It is set aside like one that cannot check; a later candidate that can
|
||||
// still checks.
|
||||
func TestInstallShTakesNoOpensslThatPassesEverySignature(t *testing.T) {
|
||||
requireTools(t, "sh", "openssl", "sha256sum", "curl")
|
||||
systemSSL, _ := exec.LookPath("openssl")
|
||||
fake := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(fake, "openssl"),
|
||||
[]byte("#!/bin/sh\ncase \"$1\" in pkeyutl) exit 0 ;; esac\nexec '"+systemSSL+"' \"$@\"\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
_, other, _ := ed25519.GenerateKey(rand.Reader)
|
||||
manifest := []byte("{\n \"version\": \"9.9.9\",\n \"binaries\": {}\n}\n")
|
||||
name, forged := releaseSigFor(other, manifest, "9.9.9")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != name {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(forged))
|
||||
}))
|
||||
defer srv.Close()
|
||||
// MUTATION: drop the probe's second verify (the one that must fail).
|
||||
// RESULT: red, "Release signature verified" over the forged signature.
|
||||
out, _ := sigHarness(t, pub, srv.URL, manifest)
|
||||
if strings.Contains(out, "Release signature verified") ||
|
||||
!(strings.Contains(out, "no openssl here can check") || strings.Contains(out, "does not verify")) {
|
||||
t.Errorf("an openssl that passes everything was taken for a check:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY.md sends a reader who does not trust our website to a copy of
|
||||
// install.sh read from this repository. Run without sudo, that copy installs
|
||||
// for the whole machine through a root step that fetches the installer from
|
||||
// the website again (whole_machine_script) and runs that, so the copy read
|
||||
// checks nothing itself. The page has to say how the copy must be run.
|
||||
func TestSecurityMdSaysHowAReadCopyOfTheInstallerMustRun(t *testing.T) {
|
||||
sh, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(sh), `whole_machine_script="https://privatae.ai/bastion/install.sh"`) {
|
||||
t.Fatal("install.sh's root step no longer fetches the website's installer: re-read SECURITY.md's first-install paragraph, then this test")
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join("..", "SECURITY.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sec := strings.Join(strings.Fields(string(raw)), " ")
|
||||
for _, want := range []string{
|
||||
"`sudo sh install.sh`",
|
||||
"`sh install.sh --just-me`",
|
||||
"fetches the installer from our website again",
|
||||
"an older release we signed",
|
||||
} {
|
||||
if !strings.Contains(sec, want) {
|
||||
t.Errorf("SECURITY.md does not say %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With no openssl that can check Ed25519, the check says so and returns, so
|
||||
// the digest check still runs; it never reads as verified.
|
||||
func TestInstallShSaysWhenItCannotCheckTheSignature(t *testing.T) {
|
||||
if _, err := exec.LookPath("sh"); err != nil {
|
||||
t.Skip("no sh")
|
||||
}
|
||||
fake := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(fake, "openssl"), []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", fake+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
pub, _, _ := ed25519.GenerateKey(rand.Reader)
|
||||
out, err := sigHarness(t, pub, "http://127.0.0.1:1", []byte("{\"version\": \"9.9.9\"}\n"), true)
|
||||
if err != nil || !strings.Contains(out, "no openssl here can check") || strings.Contains(out, "verified") ||
|
||||
!strings.Contains(out, "RETURNED") {
|
||||
t.Errorf("no capable openssl: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// The check runs on every install, after the manifest is fetched and before
|
||||
// any digest is read out of it: a digest from an unchecked manifest is the
|
||||
// thing the signature exists to stop.
|
||||
func TestInstallShChecksTheSignatureBeforeReadingTheDigest(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.sh"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
fetch := strings.Index(s, `curl -fsSL "$base/version.json" -o "$man"`)
|
||||
check := strings.Index(s, "\nrelease_signature_check \"$man\" \"$vdir\"\n")
|
||||
digest := strings.Index(s, "expected=$(awk")
|
||||
if fetch < 0 || check < 0 || digest < 0 || !(fetch < check && check < digest) {
|
||||
t.Fatalf("install.sh must fetch the manifest, check its signature, then read its digest (at %d, %d, %d)",
|
||||
fetch, check, digest)
|
||||
}
|
||||
}
|
||||
|
||||
// requireTools skips without one of these tools, unless CI says the check
|
||||
// must run (PRIVATAE_REQUIRE_OPENSSL=1, .gitea/workflows/ci.yml): there a
|
||||
// missing tool is a failure, so the signature check cannot quietly go untested.
|
||||
func requireTools(t *testing.T, tools ...string) {
|
||||
t.Helper()
|
||||
for _, tool := range tools {
|
||||
if _, err := exec.LookPath(tool); err != nil {
|
||||
skipOrFail(t, tool+" is not installed here")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func skipOrFail(t *testing.T, why string) {
|
||||
t.Helper()
|
||||
if os.Getenv("PRIVATAE_REQUIRE_OPENSSL") == "1" {
|
||||
t.Fatalf("%s, and PRIVATAE_REQUIRE_OPENSSL=1 says this check must run", why)
|
||||
}
|
||||
t.Skip(why)
|
||||
}
|
||||
Reference in New Issue
Block a user