diff --git a/CHANGELOG.md b/CHANGELOG.md index 7137818..53ee4b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,76 @@ From 0.39.1 on, a heading marks a version *not published* or *held* once that is settled. The newest entries may not be published yet; the manifest says which version is. Older entries are not marked. +## 0.57.0 + +- **Pairing with a Cloud Host is one pasted line.** A Host's "Set up a + bastion" card now gives a pairing code, `skp_…`, that carries the token, + the Host's enroll address and the SHA-256 of the Host's CA. `pair ` + (or the code on standard input after `pair -`) takes the CA from the enroll + port's own TLS chain, keeps it only under that fingerprint and only when the + port's certificate verifies under it for `host.cseries.internal`, then pairs + exactly as `--redeem-url` with `--host-ca` does. There is no certificate + file to save, so the step people skipped ("open host-ca.pem: The system + cannot find the file specified") is gone. `--host-ca` still works as before. +- **The installers pair and start in one go.** `install.sh` with + `PRIVATAE_PAIR_CODE=''` in its environment (the card's line is + `curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh`) + and `install.ps1 -Pair ''` install or update the bastion as before, + hand the code to `pair -` on standard input, then start the bastion, or restart it when it + was running, and end with one plain sentence and `status`. On Windows that + is the logon task (every running copy is stopped first, so the new one gets + its control port), or the service on a networked machine; on a machine whose + bastion is already the Windows service, `-Pair` pairs that service from an + Administrator PowerShell instead of installing a second bastion. On Linux as + yourself it is a systemd user unit, with lingering asked for and the outcome + said; on macOS a LaunchAgent; as root, the system service. Without the code + both installers behave as they did. +- **The code is in no process's arguments.** On macOS and Linux it comes + only from the environment: an argument would sit in the installer shell's + command line for the whole install, where any user of the machine can read + it with `ps`, and one at the same address could redeem it first. A + process's environment is readable only by its owner and root. `install.sh` + reads the variable before it starts any program and unsets it, so nothing + it starts inherits it. An empty or partial code is refused before the + download. `install.sh` now names no argument it refuses, so a code given in + the wrong place is never printed back. On Windows the code stays a + parameter of the script, inside the person's own PowerShell. +- **On Linux, as yourself, the copy that serves is the one the installer + started.** Before the user unit starts, every copy of your bastion running + outside it (left in a terminal, or from another path) is stopped, as + install.ps1 does on Windows. Otherwise the unit's copy only waits behind the + old one's run lock, and the old copy never reads the new pairing. The + installer then checks for a few seconds that the unit is running and keeps + the same process. A unit that keeps restarting is reported as not running, + with its log command, and the install fails. +- **A local bastion can serve a second Host, when you say so at the machine.** + A local bastion used to serve one Host and refuse any other, so a person + with two Hosts of their own (a Cloud machine beside a platform Host) could + not pair the second at all. `pair` now asks on this machine's terminal, + `/dev/tty` or the Windows console, never standard input, so a code piped + in by an installer still needs a person to answer: "This computer already + serves your Host … Add … as well? [y/N]". Only y or yes adds it; the first + pairing is untouched; with no terminal it is refused. Other callers + (`claim-anchor`, the pairing op) keep the refusal. On a local machine that + serves two Hosts, each changes only the servers it built and stops only the + coder runs it started. The name in the question is the token's, so anything + in it but a plain host name is shown quoted and escaped. +- **`pair` exits 3 when the Host is already paired here.** Nothing changes, and + the installers report it as already connected. Over TCP, with a pairing + code or `--host-ca`, a Host of the same name under another CA (Cloud names + repeat) is a plain refusal, not exit 3, before the redeem and after it. A + pairing code that names a field twice is refused. +- **An enroll port that does not answer says why within 10 seconds.** A Cloud + Host opens its pairing port only to the address the browser used when the + card was opened, for 15 minutes; from anywhere else the port does not + answer, and `pair` used to wait for the system's connect timeout. It now + stops after 10 seconds with that reason and what to do. +- **The local-mode refusal names this system's way to a networked bastion** + (`install.ps1 -Networked` from an Administrator PowerShell on Windows, + `install.sh --networked` as root elsewhere) and says that the Host already + paired would have to be paired again with it. It used to name the Linux + script on Windows. + ## 0.56.0 - **New: a Host with no platform can use the model servers on this diff --git a/README.md b/README.md index 8ddee6d..e47ef4a 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ privatae-bastion run A Windows server installs from an Administrator PowerShell with `-Networked`. See [Install](#install). +A Cloud Host's **Set up a bastion** card gives one line that installs the +bastion, pairs it and starts it, so it runs now and after a restart: +`curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh`, or +on Windows `& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…'`. + **AI readers and reviewers:** [docs/README.md](docs/README.md) lists every reference page, one line each, and [docs/claims.md](docs/claims.md) is the full table of security claims and the test behind each. **Contents:** [Why a bastion](#why-a-bastion) · [Install](#install) · @@ -106,7 +111,8 @@ privatae-bastion stepup-enroll ``` **On your own computer** (run the installer as yourself). The bastion runs -as your user in **local** mode and serves one host. The pairing's +as your user in **local** mode and serves the host you pair it with, and +another only when you answer yes on this computer. The pairing's capabilities are the authorization, and approvals work once you enrol a second factor. @@ -166,7 +172,7 @@ share on the host with your code. | Runs as | your user is fine | the system's service: root under systemd or launchd, LocalSystem on Windows | | A terminal the host opens | opens on the pairing's capabilities | waits for your approval, for that one session | | A write (an op, a ship step) | runs on the pairing's capabilities | waits for your approval, bound to that op and its arguments | -| Second pairing | refused | waits for the **anchor** host's approval | +| Second pairing | asked on this computer's terminal, added only on a yes | waits for the **anchor** host's approval | | The anchor | none | the first host paired, or `claim-anchor` | In networked mode one host is the **anchor**: the one host that approves every diff --git a/client-go/anchor.go b/client-go/anchor.go index 894c2c6..c28bb79 100644 --- a/client-go/anchor.go +++ b/client-go/anchor.go @@ -574,9 +574,10 @@ var allCaps = []string{"dcn", "egress", "harness", "inference", "models", "obser // writes a pairing PENDING when an anchor exists (pair.go), and state "" reads // active (config.go), so B comes straight up with full lanes. // -// LOCAL is exempt and must stay so: a local bastion serves one host and cannot -// be anchored at all (cmdClaimAnchor refuses outside networked mode), so the -// pairing IS the authorization there. Most bastions are local. +// LOCAL is exempt and must stay so: a local bastion serves the one host it was +// paired with, and a second only on a yes typed at the machine (pair_terminal.go), +// and it cannot be anchored at all (cmdClaimAnchor refuses outside networked +// mode), so the pairing IS the authorization there. Most bastions are local. // // self-update is deliberately INSIDE the refusal. Directing a binary swap is the // most dangerous thing on the list, and an unanchored networked box cannot say @@ -1033,7 +1034,7 @@ func cmdClaimAnchor(token string, rotate bool, owner, host string) error { return err } if boxMode() != modeNetworked { - return fmt.Errorf("claim-anchor needs networked mode — a local bastion serves one host and needs no anchor (%s, then `privatae-bastion set-mode networked`)", adminHowTo()) + return fmt.Errorf("claim-anchor needs networked mode — a local bastion serves only the Hosts its person said yes to and needs no anchor (%s, then `privatae-bastion set-mode networked`)", adminHowTo()) } cfgs, err := loadPairings() if err != nil { diff --git a/client-go/coders.go b/client-go/coders.go index 66a8788..1b836d4 100644 --- a/client-go/coders.go +++ b/client-go/coders.go @@ -1229,10 +1229,17 @@ func coderContainerGone(rt, cname string) bool { // anchor pairing on an anchored box (serviceOwnerRefusal's rule). // An unknown owner proves nothing about self, so it gets the anchor-only rule // too: every run this bastion starts records and labels its owner. +// +// A local machine has no anchor, so anchorOnly lets every pairing through +// there; once it serves several Hosts (localServesSeveralHosts), each stops +// only the runs it started, as with the servers each built. func coderOwnerRefusal(self, owner string) string { if owner != "" && owner == self { return "" } + if localServesSeveralHosts() { + return "refused: that run was started by " + orUnknown(owner) + "; on a local machine that serves several Hosts each stops only the runs it started" + } if why := anchorOnly(self, "stopping a run another host started"); why != "" { return "refused: that run was started by " + orUnknown(owner) + "; " + why } diff --git a/client-go/config.go b/client-go/config.go index ebb1655..70a528e 100644 --- a/client-go/config.go +++ b/client-go/config.go @@ -615,7 +615,7 @@ func removeStalePairingFile(hostName string) error { func checkPairConflict(cfgs []*Config, handle string, replace bool) (*Config, error) { existing := pairingFor(cfgs, handle) if existing != nil && !replace { - return nil, fmt.Errorf("already paired with %s — this daemon serves multiple hosts, so pairing never overwrites; to rotate that host's credentials run `privatae-bastion pair --replace `", existing.HostName) + return nil, &alreadyPairedError{host: existing.HostName, msg: fmt.Sprintf("already paired with %s — this daemon serves multiple hosts, so pairing never overwrites; to rotate that host's credentials run `privatae-bastion pair --replace `", existing.HostName)} } if existing == nil && replace { return nil, fmt.Errorf("pair --replace: no existing pairing matches '%s' — pair it normally first", handle) diff --git a/client-go/install_ps1_test.go b/client-go/install_ps1_test.go new file mode 100644 index 0000000..9a9f4a0 --- /dev/null +++ b/client-go/install_ps1_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// install.ps1 has no Windows runner here, so its -Pair shape is read from the +// file: the code is checked before anything is downloaded, reaches `pair -` +// on standard input and never an argument, exit 3 is success, and the +// install with no flag still ends the way it did. +func TestInstallPs1PairShape(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "deploy", "install.ps1")) + if err != nil { + t.Fatal(err) + } + s := string(raw) + for _, want := range []string{ + "param([switch]$Networked, [string]$Pair)", + "$Pair | & $exe pair -", + "elseif ($rc -eq 3)", + "Start-ScheduledTask -TaskName 'PrivataeBastion'", + "Restart-Service -Name 'PrivataeBastion'", + `Write-Host "Open a NEW terminal, then: privatae-bastion pair "`, + } { + if !strings.Contains(s, want) { + t.Errorf("install.ps1 lacks %q", want) + } + } + for _, bad := range []string{"pair $Pair", "pair -- $Pair", "-ArgumentList $Pair", "Write-Host $Pair", "$Pair\"", "'$Pair'"} { + if strings.Contains(s, bad) { + t.Errorf("install.ps1 must never put the code in an argument or the output: %q", bad) + } + } + check, fetch := strings.Index(s, "StartsWith('skp_')"), strings.Index(s, `Write-Host "Fetching manifest..."`) + if check < 0 || fetch < 0 || check > fetch { + t.Error("the pairing code must be checked before the manifest is fetched") + } +} + +// The -Pair block itself, run by pwsh against a stand-in bastion +// (testdata/install_ps1_pair.ps1). Skipped where pwsh is not installed. +func TestInstallPs1PairBlockRunsUnderPwsh(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + t.Skip("pwsh is not installed: install.ps1's -Pair block is checked only by TestInstallPs1PairShape here") + } + out, err := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", + filepath.Join("testdata", "install_ps1_pair.ps1"), filepath.Join("..", "deploy", "install.ps1")).CombinedOutput() + if err != nil || !strings.HasPrefix(strings.TrimSpace(string(out)), "ok:") { + t.Fatalf("%v\n%s", err, out) + } +} diff --git a/client-go/install_sh_test.go b/client-go/install_sh_test.go index a1f3b46..de9d6ba 100644 --- a/client-go/install_sh_test.go +++ b/client-go/install_sh_test.go @@ -3,10 +3,13 @@ package main import ( + "crypto/sha256" + "encoding/hex" "os" "os/exec" "path/filepath" "regexp" + "strings" "testing" ) @@ -74,3 +77,358 @@ func TestInstallShKeepsAPairedLocalMachineLocal(t *testing.T) { t.Fatal("a machine with no pairing gets the networked default") } } + +// installShPrivatePIDs is what a test that reaches install.sh's +// start_bastion runs under. start_bastion stops every copy of this user's +// bastion it can see in /proc, and on a machine that runs a real bastion as +// this user (root on a server) that would be the real one. So the test must +// see only its own processes: nil when it already runs in a PID namespace of +// its own (CI's container), the unshare prefix that makes one when it can, +// and a skip otherwise. +// +// The machine's own PID namespace is the kernel's initial one, whose number +// is fixed (PROC_PID_INIT_INO, 0xEFFFFFFC); any other is a namespace of its +// own. NSpid in /proc/self/status cannot tell: inside a container /proc is +// the container's, so it shows one level there too. +func installShPrivatePIDs(t *testing.T) []string { + t.Helper() + ns, err := os.Readlink("/proc/self/ns/pid") + if err == nil && ns != "pid:[4026531836]" { + return nil + } + if err == nil && os.Geteuid() == 0 { + if u, err := exec.LookPath("unshare"); err == nil { + wrap := []string{u, "--pid", "--fork", "--mount-proc"} + if exec.Command(wrap[0], append(wrap[1:], "true")...).Run() == nil { + return wrap + } + } + } + t.Skip("install.sh's start_bastion stops every copy of this user's bastion; run this test in a PID namespace of its own (unshare --pid --fork --mount-proc, as root)") + return nil +} + +// pairShSetup is a stand-in machine for install.sh: a log directory, a +// home, and a PATH directory of stand-ins for systemctl, loginctl and sleep, +// each logging what it was given. unit is how the stand-in systemd reports +// the user unit: "steady" (one process, running) or "flapping" (a new +// process on every look, auto-restarting). +type pairShSetup struct { + dir, logs, bin, path, home string +} + +func newPairShSetup(t *testing.T) *pairShSetup { + t.Helper() + d := t.TempDir() + s := &pairShSetup{dir: d, logs: filepath.Join(d, "log"), bin: filepath.Join(d, "bin"), path: filepath.Join(d, "path"), home: filepath.Join(d, "home")} + for _, p := range []string{s.logs, s.bin, s.path, s.home} { + if err := os.MkdirAll(p, 0o700); err != nil { + t.Fatal(err) + } + } + s.write(t, filepath.Join(s.path, "systemctl"), `#!/bin/sh +echo "$*" >> "$LOGS/systemctl" +case "$*" in + *"show -p MainPID --value"*) + if [ "$UNIT" = flapping ]; then n=$(cat "$LOGS/n" 2>/dev/null || echo 100); n=$((n + 1)); echo "$n" > "$LOGS/n"; echo "$n"; else echo 4242; fi ;; + *"show -p ActiveState --value"*) if [ "$UNIT" = flapping ]; then echo activating; else echo active; fi ;; + *"show -p SubState --value"*) if [ "$UNIT" = flapping ]; then echo auto-restart; else echo running; fi ;; +esac +exit 0 +`) + s.write(t, filepath.Join(s.path, "loginctl"), "#!/bin/sh\necho \"$*\" >> \"$LOGS/loginctl\"\nexit \"$LINGER_RC\"\n") + s.write(t, filepath.Join(s.path, "sleep"), "#!/bin/sh\nexit 0\n") + return s +} + +func (s *pairShSetup) write(t *testing.T, p, body string) { + t.Helper() + if err := os.WriteFile(p, []byte(body), 0o700); err != nil { + t.Fatal(err) + } +} + +func (s *pairShSetup) env(extra ...string) []string { + return append([]string{"PATH=" + s.path + ":/usr/bin:/bin", "HOME=" + s.home, "XDG_CONFIG_HOME=" + filepath.Join(s.home, ".config"), + "LOGS=" + s.logs, "TMPDIR=" + s.dir}, extra...) +} + +// standInCopy puts a copy of this test binary at dir/privatae-bastion: run +// with installShStandInRun it is a bastion that waits to be stopped, under +// the program name install.sh looks for. +func standInCopy(t *testing.T, dir string) string { + t.Helper() + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(self) + if err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "privatae-bastion") + if err := os.WriteFile(p, b, 0o755); err != nil { + t.Fatal(err) + } + return p +} + +// pairShHarness runs install.sh's pairing functions against a stand-in +// bastion, systemctl and loginctl. It returns the run's output, the log +// directory and its error. With copyRunning, a copy of the bastion from another +// path is running when the functions start, and log/copy says whether it +// still was when they ended. +func pairShHarness(t *testing.T, root, pairRC, lingerRC string) (string, string, error) { + t.Helper() + return pairShHarnessWith(t, root, pairRC, lingerRC, "steady", false) +} + +func pairShHarnessWith(t *testing.T, root, pairRC, lingerRC, unit string, copyRunning bool) (string, string, error) { + t.Helper() + wrap := installShPrivatePIDs(t) + s := newPairShSetup(t) + fake := filepath.Join(s.bin, "privatae-bastion") + s.write(t, fake, `#!/bin/sh +echo "HOME=$HOME $*" >> "$LOGS/argv" +case "$1" in + pair) cat > "$LOGS/stdin"; exit "$PAIR_RC" ;; + status) echo "status: paired" ;; +esac +exit 0 +`) + copyPath := "" + if copyRunning { + other := filepath.Join(s.dir, "other-install") + if err := os.MkdirAll(other, 0o700); err != nil { + t.Fatal(err) + } + copyPath = standInCopy(t, other) + } + fns := installShFunc(t, "pair_as") + installShFunc(t, "bastion_copy") + installShFunc(t, "stop_user_copies") + + installShFunc(t, "user_unit_stays_up") + installShFunc(t, "start_bastion") + installShFunc(t, "pair_and_start") + script := fns + ` +if [ -n "$COPY" ]; then + "$COPY" ` + installShStandInRun + ` >/dev/null 2>&1 & + copy=$! + i=0 + while ! bastion_copy "$copy" && [ "$i" -lt 100 ]; do i=$((i + 1)); /bin/sleep 0.05; done + bastion_copy "$copy" || { echo "the stand-in copy never started" >&2; exit 97; } +fi +rc=0 +pair_and_start "$1" "$2" "$3" linux || rc=$? +if [ -n "$COPY" ]; then + if bastion_copy "$copy"; then echo alive > "$LOGS/copy"; kill -9 "$copy"; else echo gone > "$LOGS/copy"; fi +fi +exit "$rc" +` + args := append(append([]string{}, wrap...), "sh", "-c", script, "sh", fake, "skp_INVENTEDCODE", root) + cmd := exec.Command(args[0], args[1:]...) + cmd.Env = s.env("PAIR_RC="+pairRC, "LINGER_RC="+lingerRC, "UNIT="+unit, "COPY="+copyPath) + out, err := cmd.CombinedOutput() + return string(out), s.logs, err +} + +func readPairShLog(t *testing.T, dir, name string) string { + t.Helper() + b, _ := os.ReadFile(filepath.Join(dir, name)) + return string(b) +} + +// The pairing functions hand the code to `pair -` on standard input, never +// as an argument, then write and start a systemd user unit, ask for +// lingering, and end with one plain sentence. Exit 3 (already paired with +// this Host) is success. +func TestInstallShPairsOverStdinAndStartsAUserUnit(t *testing.T) { + out, logs, err := pairShHarness(t, "0", "0", "0") + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + argv := readPairShLog(t, logs, "argv") + if !strings.Contains(argv, " pair -\n") || strings.Contains(argv, "skp_") { + t.Fatalf("the code must reach `pair -` on stdin and never an argument: %q", argv) + } + if got := readPairShLog(t, logs, "stdin"); got != "skp_INVENTEDCODE\n" { + t.Fatalf("pair's standard input: %q", got) + } + home := strings.TrimPrefix(strings.Fields(argv)[0], "HOME=") + unit, err := os.ReadFile(filepath.Join(home, ".config", "systemd", "user", "privatae-bastion.service")) + if err != nil || !regexp.MustCompile(`(?m)^ExecStart=\S+/privatae-bastion run$`).Match(unit) || !strings.Contains(string(unit), "WantedBy=default.target") { + t.Fatalf("the user unit: %v\n%s", err, unit) + } + sc := readPairShLog(t, logs, "systemctl") + for _, want := range []string{"--user daemon-reload", "--user enable privatae-bastion", "--user restart privatae-bastion"} { + if !strings.Contains(sc, want+"\n") { + t.Fatalf("systemctl was not asked to %q: %q", want, sc) + } + } + if !strings.Contains(readPairShLog(t, logs, "loginctl"), "enable-linger ") { + t.Fatal("lingering was not asked for") + } + if !strings.Contains(out, "status: paired") || !strings.HasSuffix(strings.TrimSpace(out), + "This machine is paired. The bastion runs as your systemd user service privatae-bastion, and starts at boot.") { + t.Fatalf("the ending: %s", out) + } + + out, _, err = pairShHarness(t, "0", "3", "1") + if err != nil || !strings.Contains(out, "already paired with this Host") || !strings.Contains(out, "sudo loginctl enable-linger") { + t.Fatalf("exit 3 is success, and a refused linger says how to get it: %v\n%s", err, out) + } + + out, logs, err = pairShHarness(t, "0", "1", "0") + if err == nil || strings.Contains(readPairShLog(t, logs, "systemctl"), "restart") || !strings.Contains(out, "pairing did not finish") { + t.Fatalf("a failed pair must stop before starting anything: %v\n%s", err, out) + } + + out, logs, err = pairShHarness(t, "1", "0", "0") + if err != nil || !strings.Contains(readPairShLog(t, logs, "argv"), "HOME=/root ") || + readPairShLog(t, logs, "systemctl") != "restart privatae-bastion\n" || strings.Contains(out, "user service") { + t.Fatalf("as root it pairs as the service's state and restarts the system service: %v\n%s\n%s", err, out, readPairShLog(t, logs, "systemctl")) + } +} + +// Linux, as yourself: a copy of the bastion already running outside the +// user unit (from a terminal, or another path) is stopped before the unit +// starts, so the unit's copy is the one that serves; and a unit that does not +// stay up is said plainly, with a failure, never reported as running. +func TestInstallShStopsAnOutsideCopyAndChecksTheUnitStaysUp(t *testing.T) { + out, logs, err := pairShHarnessWith(t, "0", "0", "0", "steady", true) + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + if got := strings.TrimSpace(readPairShLog(t, logs, "copy")); got != "gone" { + t.Fatalf("the copy running outside the unit must be stopped, it is %q\n%s", got, out) + } + if !strings.Contains(out, "Stopped 1 copy(ies) of the bastion running outside its service") { + t.Fatalf("the stop is said: %s", out) + } + sc := readPairShLog(t, logs, "systemctl") + if i, j := strings.Index(sc, "--user stop privatae-bastion\n"), strings.Index(sc, "--user restart privatae-bastion\n"); i < 0 || j < i { + t.Fatalf("the unit is stopped before the copies and started after: %q", sc) + } + if !strings.HasSuffix(strings.TrimSpace(out), "This machine is paired. The bastion runs as your systemd user service privatae-bastion, and starts at boot.") { + t.Fatalf("the ending: %s", out) + } + + out, _, err = pairShHarnessWith(t, "0", "0", "0", "flapping", false) + if err == nil || !strings.Contains(out, "is not staying up") || !strings.Contains(out, "activating/auto-restart") || + !strings.Contains(out, "journalctl --user -u privatae-bastion") || strings.Contains(out, "The bastion runs as your systemd user service") { + t.Fatalf("a unit that restarts must be reported as not running, and fail: %v\n%s", err, out) + } +} + +// The whole of install.sh, the card's way: the code in PRIVATAE_PAIR_CODE +// and no argument. Every program it starts (stand-ins for uname, id, curl, +// printf, systemctl, loginctl and sleep, and the downloaded bastion) records +// its own arguments, the command line of the shell that started it, and +// whether the code is in its environment. The code must reach `pair -` on +// standard input and appear in none of those. Nothing leaves this machine: +// curl is a stand-in serving a manifest and a stand-in bastion. +func TestInstallShTakesTheCodeOnlyFromTheEnvironment(t *testing.T) { + wrap := installShPrivatePIDs(t) + s := newPairShSetup(t) + const code = "skp_eyJ2IjoxLCJ0IjoiSU5WRU5URUQtRU5WLUNPREUifQ" + record := `echo "$(basename "$0") argv=[$*] parent=[$(tr '\0' ' ' < /proc/$PPID/cmdline)] env=[${PRIVATAE_PAIR_CODE+set}] envcode=[$(env | grep -c "` + code + `")]" >> "$LOGS/procs" +` + bastion := "#!/bin/sh\n" + record + `case "$1" in + pair) cat > "$LOGS/stdin" ;; + status) echo "status: paired" ;; +esac +exit 0 +` + s.write(t, filepath.Join(s.dir, "bastion.bin"), bastion) + sum := sha256.Sum256([]byte(bastion)) + s.write(t, filepath.Join(s.dir, "version.json"), "{\n \"linux-amd64\": {\n \"url\": \"https://dl.privatae.ai/dl/privatae-bastion-linux-amd64?v=0.0.0\",\n \"sha256\": \""+hex.EncodeToString(sum[:])+"\"\n }\n}\n") + stand := map[string]string{ + "uname": `case "$1" in -s) echo Linux ;; -m) echo x86_64 ;; *) echo Linux ;; esac`, + "id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`, + "curl": `for a; do o=$a; done; case "$2" in */version.json) cp "$STAND/version.json" "$o" ;; *) cp "$STAND/bastion.bin" "$o" ;; esac`, + "printf": `exec /usr/bin/printf "$@"`, + } + for name, body := range stand { + s.write(t, filepath.Join(s.path, name), "#!/bin/sh\n"+record+body+"\n") + } + for _, name := range []string{"systemctl", "loginctl", "sleep"} { + p := filepath.Join(s.path, name) + b, _ := os.ReadFile(p) + s.write(t, p, strings.Replace(string(b), "#!/bin/sh\n", "#!/bin/sh\n"+record, 1)) + } + sh, err := filepath.Abs(filepath.Join("..", "deploy", "install.sh")) + if err != nil { + t.Fatal(err) + } + args := append(append([]string{}, wrap...), "sh", sh) + cmd := exec.Command(args[0], args[1:]...) + cmd.Env = s.env("PRIVATAE_PAIR_CODE="+code, "STAND="+s.dir, "LINGER_RC=0", "UNIT=steady") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%v\n%s", err, out) + } + if got := readPairShLog(t, s.logs, "stdin"); got != code+"\n" { + t.Fatalf("pair's standard input: %q\n%s", got, out) + } + procs := readPairShLog(t, s.logs, "procs") + for _, who := range []string{"uname argv=[-s]", "curl argv=[", "privatae-bastion argv=[pair -]", "privatae-bastion argv=[status]", "systemctl argv=[--user restart privatae-bastion]"} { + if !strings.Contains(procs, who) { + t.Fatalf("no record of %q: the stand-ins did not run as expected\n%s\n%s", who, procs, out) + } + } + if strings.Contains(procs, code) || strings.Contains(procs, "INVENTED-ENV-CODE") { + t.Fatalf("the code is in a program's arguments or its shell's command line:\n%s", procs) + } + if strings.Contains(procs, "env=[set]") || !strings.Contains(procs, "envcode=[0]") || strings.Contains(procs, "envcode=[1]") { + t.Fatalf("a program the installer started inherited the code in its environment:\n%s", procs) + } + if strings.Contains(string(out), code) { + t.Fatalf("the installer printed the code:\n%s", out) + } + if !strings.HasSuffix(strings.TrimSpace(string(out)), "https://privatae.ai/bastion/LICENSE.txt") || !strings.Contains(string(out), "This machine is paired.") { + t.Fatalf("the ending: %s", out) + } +} + +// A PRIVATAE_PAIR_CODE that is empty or not a whole pairing code is refused +// before anything is downloaded, and never echoed; so is any argument, a +// code given as one (the removed --pair, or the code alone) included. +// +// The script runs with stand-ins for id (not root) and curl (records that it +// was reached, fetches nothing), so an installer whose refusal broke can +// neither reach the network nor install anything as root on the machine +// running the test: it fails here instead. +func TestInstallShRefusesABadPairCodeBeforeDownloading(t *testing.T) { + const code = "skp_eyJ2IjoxfQ" + stand := t.TempDir() + for name, body := range map[string]string{ + "id": `case "$1" in -u) echo 1000 ;; -un) echo tester ;; *) echo "uid=1000(tester)" ;; esac`, + "curl": `echo "REACHED-THE-DOWNLOAD" >&2; exit 1`, + } { + if err := os.WriteFile(filepath.Join(stand, name), []byte("#!/bin/sh\n"+body+"\n"), 0o700); err != nil { + t.Fatal(err) + } + } + for _, c := range []struct { + env []string + args []string + }{ + {[]string{"PRIVATAE_PAIR_CODE="}, nil}, + {[]string{"PRIVATAE_PAIR_CODE=skp_"}, nil}, + {[]string{"PRIVATAE_PAIR_CODE=skb_aG9tZS5zZWNyZXQteA"}, nil}, + {[]string{"PRIVATAE_PAIR_CODE=" + code + " trailing"}, nil}, + {[]string{"PRIVATAE_PAIR_CODE=" + code + "%3D"}, nil}, + {nil, []string{"--pair", code}}, + {nil, []string{"--pair=" + code}}, + {nil, []string{code}}, + {nil, []string{"--networkd"}}, + } { + home := t.TempDir() + cmd := exec.Command("sh", append([]string{filepath.Join("..", "deploy", "install.sh")}, c.args...)...) + cmd.Env = append([]string{"PATH=" + stand + ":/usr/bin:/bin", "HOME=" + home, "TMPDIR=" + home}, c.env...) + out, err := cmd.CombinedOutput() + ee, ok := err.(*exec.ExitError) + if !ok || ee.ExitCode() != 2 || strings.Contains(string(out), "Fetching manifest") || strings.Contains(string(out), "REACHED-THE-DOWNLOAD") { + t.Fatalf("%v %v: want exit 2 before the download, got %v\n%s", c.env, c.args, err, out) + } + if strings.Contains(string(out), code) || strings.Contains(string(out), "skb_") || strings.Contains(string(out), "networkd") { + t.Fatalf("%v %v: the refusal echoes what it was given: %s", c.env, c.args, out) + } + } +} diff --git a/client-go/main.go b/client-go/main.go index 04c234c..9c48b99 100644 --- a/client-go/main.go +++ b/client-go/main.go @@ -37,6 +37,13 @@ Usage: that CA alone (name host.cseries.internal), the lanes go to the lane port the Host names, and the CA is pinned for life. No coder runs over this pairing + privatae-bastion pair + the same pairing from the one code a Cloud Host's card gives: + it carries the enroll address and the CA's fingerprint, and + the CA is taken from the enroll port only under that + fingerprint. On a local machine that already serves a Host, + it asks here whether to add this one as well. Exit 3: already + paired with this Host, nothing changed privatae-bastion run bring up the tunnel + lanes for EVERY paired host (a loopback control port per host for the verbs below: 127.0.0.1:7878, then 7879, …) @@ -188,7 +195,9 @@ func takeHostFlag(args []string) (string, []string) { } const pairUsage = "usage: privatae-bastion pair [--replace] [--redeem-url unix:/// | --redeem-url https://:/ --host-ca ] | -\n" + - " - reads the token from standard input, so it stays out of the process list and shell history" + " privatae-bastion pair | -\n" + + " - reads the token or pairing code from standard input, so it stays out of the process list and shell history\n" + + " exit status: 0 paired, 3 already paired with this Host (nothing changed), anything else a failure" // parsePairArgs reads `pair`'s flags in any order: --replace, --redeem-url // for a Host with no platform (pair_unix.go, pair_tcp.go) and --host-ca for @@ -258,6 +267,15 @@ func main() { os.Exit(2) } err = cmdPair(token, redeemURL, hostCA, replace) + // Already paired with this Host, nothing changed: its own exit status, + // so the installers (install.ps1 -Pair, install.sh with + // PRIVATAE_PAIR_CODE) report "already connected". + var already *alreadyPairedError + if errors.As(err, &already) { + auditFlush(auditFlushOnExit) + fmt.Fprintln(os.Stderr, already) + os.Exit(pairExitAlreadyPaired) + } case "run": err = cmdRun() case "observe:baseline": diff --git a/client-go/main_test.go b/client-go/main_test.go index ce74e89..27455ce 100644 --- a/client-go/main_test.go +++ b/client-go/main_test.go @@ -36,8 +36,13 @@ import ( "os" "strings" "testing" + "time" ) +// installShStandInRun is the argument that makes this test binary stand in +// for a running bastion (TestMain). +const installShStandInRun = "install-sh-stand-in-run" + func TestMain(m *testing.M) { // The op sandbox re-execs its own binary (ops_exec_linux.go); under // `go test` that binary is this one, so the child must be answered here @@ -62,6 +67,13 @@ func TestMain(m *testing.M) { if len(os.Args) >= 3 && os.Args[1] == rootExecConnectSubcommand { os.Exit(rootExecConnectMain(os.Args[2:])) } + // install_sh_test.go runs a copy of this binary named privatae-bastion as + // a bastion left running outside its service: it only waits to be + // stopped, and never starts the suite. + if len(os.Args) >= 2 && os.Args[1] == installShStandInRun { + time.Sleep(2 * time.Minute) + os.Exit(0) + } // Likewise when git runs this binary as the bastion's credential helper // (claims_git_test.go): answer as the helper, with the config dir the // test named, and never start the suite. diff --git a/client-go/mode.go b/client-go/mode.go index 4336bad..19af086 100644 --- a/client-go/mode.go +++ b/client-go/mode.go @@ -88,11 +88,13 @@ func setBoxMode(mode string) error { } } if mode == modeLocal { - // Going back to local must not strand hosts: a local box serves ONE host, - // so with several pairings the box would be lying about its own rule. + // Going back to local must not strand hosts: a local box serves a second + // host only on a yes typed at this machine (pair_terminal.go), and these + // were paired under the networked rules, so with several pairings the box + // would serve hosts nobody here said yes to. cfgs, err := loadPairings() if err == nil && len(cfgs) > 1 { - return fmt.Errorf("cannot switch to local mode: %d hosts are paired and a local bastion serves one — remove the others first", len(cfgs)) + return fmt.Errorf("cannot switch to local mode: %d hosts are paired, and a local bastion adds a second host only on a yes typed at this machine — remove the others first, then pair them again here", len(cfgs)) } if currentAnchor() != nil { return errors.New("cannot switch to local mode: this box is anchored (anchor.json) — anchoring is a networked-mode concept") diff --git a/client-go/pair.go b/client-go/pair.go index 7c928b0..9779977 100644 --- a/client-go/pair.go +++ b/client-go/pair.go @@ -163,10 +163,12 @@ type redeemResp struct { // mode rule, and the anchoring rule. Returns the existing pairing on a // --replace and the state the new pairing file must carry. // -// - LOCAL mode (mode.go): one host, full stop. A second `pair` is refused with -// a plain message; only --replace of the one host is allowed. This is the -// entire protection for a single-user machine — a second host appearing -// means someone else got a token onto the box. +// - LOCAL mode (mode.go): one host unless the person says otherwise. A +// second `pair` is refused here with localSecondHostError; `pair` itself +// turns that into a question asked on this machine's terminal +// (pair_terminal.go), and every other caller keeps the refusal. On a +// single-user machine a second host appearing unasked means someone else +// got a token onto the box, so only a yes typed here adds one. // - NETWORKED + anchored (anchor.go): every pairing after the anchor is written // PENDING. It gets nothing until the anchor host's grant arrives. // - --replace keeps the pairing's current state: rotating credentials is not @@ -191,11 +193,14 @@ func pairPrecheck(cfgs []*Config, handle string, replace, forceActive bool) (exi return existing, existing.State, nil } if boxMode() == modeLocal && len(cfgs) > 0 { - return nil, "", fmt.Errorf("this bastion is in local mode and already serves %s — a local bastion serves one host. "+ - "To rotate that host's credentials: `privatae-bastion pair --replace `. "+ - "To serve several hosts, reinstall as a networked bastion (install.sh --networked, root) and anchor it", - cfgs[0].HostName) + return nil, "", &localSecondHostError{serving: cfgs[0].HostName} } + return pairPrecheckState(forceActive) +} + +// pairPrecheckState is the state a new pairing gets once pairPrecheck's walls +// are passed: active for claim-anchor, pending behind an anchor, else "". +func pairPrecheckState(forceActive bool) (*Config, string, error) { if forceActive { return nil, pairingActive, nil } @@ -205,8 +210,73 @@ func pairPrecheck(cfgs []*Config, handle string, replace, forceActive bool) (exi return nil, "", nil } +// localSecondHostError is pairPrecheck's refusal on a local machine that +// already serves a host. cmdPair asks the person instead (confirmAddHost). +type localSecondHostError struct{ serving string } + +func (e *localSecondHostError) Error() string { return localOneHostRefusal(e.serving) } + +// localOneHostRefusal says why a second host was not added to a local +// bastion and what the person can do, in this system's own words. A networked +// reinstall starts a bastion with its own state, so it says plainly that the +// host already here would have to be paired again. +func localOneHostRefusal(serving string) string { + networked := "reinstall it as root with install.sh --networked" + if runtime.GOOS == "windows" { + networked = "reinstall it from an Administrator PowerShell with install.ps1 -Networked" + } + return fmt.Sprintf("this bastion is in local mode and already serves %s, and it adds another Host only when you "+ + "answer yes at this computer's terminal. To rotate %s's credentials instead: `privatae-bastion pair --replace `. "+ + "A machine that other people's Hosts pair to is a networked bastion: %s. That makes a new bastion with "+ + "state of its own, so %s has to be paired with it again.", serving, serving, networked, serving) +} + +// alreadyPairedError: the host a pair names is already paired here, and +// nothing was changed. `pair` exits pairExitAlreadyPaired on it, so an +// installer can tell "already connected" from a failure. +type alreadyPairedError struct{ host, msg string } + +func (e *alreadyPairedError) Error() string { return e.msg } + +// pairExitAlreadyPaired is `pair`'s exit status for alreadyPairedError. +const pairExitAlreadyPaired = 3 + +// sameNameOtherHost refuses, as a plain failure and never as +// alreadyPairedError, a pair whose Host has the name of prev but trusts +// another CA (wantCA, its fingerprint). A Cloud Host's name can repeat; its CA +// cannot, so that is another Host and never "already connected". nil when +// prev is nil or holds that same CA. +func sameNameOtherHost(prev *Config, wantCA string) error { + if prev == nil || caSHA256(prev.CACert) == wantCA { + return nil + } + return fmt.Errorf("this computer already serves a different Host named %s (its certificate is not this one's), "+ + "and one bastion cannot serve two Hosts of the same name yet; nothing was paired", prev.HostName) +} + +func alreadyPairedRefusal(host string) error { + return &alreadyPairedError{host: host, msg: fmt.Sprintf("already paired with %s — run `privatae-bastion pair --replace ` to rotate its credentials", host)} +} + +// pairOpts are what `pair` brings beyond the token: where to redeem, the +// Host CA as a file or as a pairing code's fingerprint, and the person to ask +// before a local machine adds a second host (nil: refuse, as every caller but +// `pair` does). +type pairOpts struct { + redeemURL string + hostCAFile string + caSHA256 string + replace bool + forceActive bool + askSecond func(serving, newHost string) (bool, error) +} + func cmdPair(token, redeemURL, hostCAFile string, replace bool) error { - cfg, all, err := pairWithTokenVia(token, redeemURL, hostCAFile, replace, false) + token, opts, err := pairOptsFor(token, redeemURL, hostCAFile, replace) + if err != nil { + return err + } + cfg, all, err := pairWith(token, opts) if err != nil { return err } @@ -236,6 +306,26 @@ func cmdPair(token, redeemURL, hostCAFile string, replace bool) error { return nil } +// pairOptsFor is what `pair`'s arguments come to: the token to redeem and +// the options for it, the person at this machine to ask about a second host +// among them. A pairing code (pair_code.go) carries the redeem address and the +// Host CA's fingerprint itself, so it takes neither flag. +func pairOptsFor(token, redeemURL, hostCAFile string, replace bool) (string, pairOpts, error) { + opts := pairOpts{redeemURL: redeemURL, hostCAFile: hostCAFile, replace: replace, askSecond: confirmAddHost} + if !isPairCode(token) { + return token, opts, nil + } + if redeemURL != "" || hostCAFile != "" { + return "", opts, errors.New("a pairing code (skp_…) carries its own address and certificate; give it alone, without --redeem-url or --host-ca") + } + pc, err := decodePairCode(token) + if err != nil { + return "", opts, err + } + opts.redeemURL, opts.caSHA256 = pc.RedeemURL, pc.CASHA256 + return pc.Token, opts, nil +} + // pairReport is what `pair` prints once the pairing stands. Whatever the form // (anchored just now, over TCP, over a unix socket, pending, or over the // tunnel), it ends by saying to enrol a second factor when this pairing has @@ -338,6 +428,14 @@ func pairWithTokenAt(token, redeemURL string, replace, forceActive bool) (*Confi // redeem URL needs (pair_tcp.go): the Host's enroll port, trusted through // that CA alone. A unix:// or platform redeem takes no --host-ca. func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive bool) (*Config, []*Config, error) { + return pairWith(token, pairOpts{redeemURL: redeemURL, hostCAFile: hostCAFile, replace: replace, forceActive: forceActive}) +} + +// pairWith is every pairing's one path. opts.caSHA256 set (a pairing code) +// takes the Host CA from the enroll port once the walls below are passed, +// kept only under that fingerprint (fetchPinnedHostCA). +func pairWith(token string, opts pairOpts) (*Config, []*Config, error) { + redeemURL, hostCAFile, replace, forceActive := opts.redeemURL, opts.hostCAFile, opts.replace, opts.forceActive // ONE BASTION PER MACHINE, and this is the check that matters most. // // It is FIRST on purpose. loadPairings below reaches pairingsDir -> MkdirAll, @@ -367,11 +465,16 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive if err != nil { return nil, nil, err } - if hostCAFile == "" { + switch { + case opts.caSHA256 != "": + // A pairing code: the CA comes from the enroll port after the + // walls, under the code's fingerprint. + case hostCAFile == "": return nil, nil, errors.New("--redeem-url https://… needs --host-ca : it is the only certificate the redeem trusts") - } - if hostCA, err = loadHostCA(hostCAFile); err != nil { - return nil, nil, err + default: + if hostCA, err = loadHostCA(hostCAFile); err != nil { + return nil, nil, err + } } tcpRedeem, transport = u, transportTCP default: @@ -397,12 +500,41 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive return nil, nil, err } existing, state, err := pairPrecheck(cfgs, handle, replace, forceActive) + var second *localSecondHostError + if errors.As(err, &second) && opts.askSecond != nil { + yes, aerr := opts.askSecond(second.serving, handle) + if aerr != nil { + return nil, nil, aerr + } + if !yes { + return nil, nil, fmt.Errorf("nothing was paired: %s stays the only Host this computer serves", second.serving) + } + existing, state, err = pairPrecheckState(forceActive) + } + // The CA this pair trusts, as a fingerprint: the pairing code's, or that + // of the --host-ca file. "" for a platform or unix-socket pairing, whose + // CA comes back in the answer. + wantCA := opts.caSHA256 + if wantCA == "" && hostCA != "" { + wantCA = caSHA256(hostCA) + } + var already *alreadyPairedError + if errors.As(err, &already) && wantCA != "" { + if refusal := sameNameOtherHost(pairingFor(cfgs, handle), wantCA); refusal != nil { + return nil, nil, refusal + } + } if err != nil { return nil, nil, err } if err := replaceKeepsTransport(existing, transport); err != nil { return nil, nil, err } + if tcpRedeem != nil && hostCA == "" { + if hostCA, err = fetchPinnedHostCA(tcpRedeem, opts.caSHA256); err != nil { + return nil, nil, err + } + } if tcpRedeem != nil { if err := tcpPinRefusal(existing, hostCA); err != nil { return nil, nil, err @@ -438,7 +570,7 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive if prev := pairingFor(cfgs, rr.hostName()); prev != nil && validHostName(rr.hostName()) { existing = prev if !replace { - return nil, nil, fmt.Errorf("already paired with %s — run `privatae-bastion pair --replace ` to rotate its credentials", prev.HostName) + return nil, nil, alreadyPairedRefusal(prev.HostName) } if err := replaceKeepsTransport(existing, transportUnix); err != nil { return nil, nil, err @@ -463,7 +595,10 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive if prev := pairingFor(cfgs, rr.hostName()); prev != nil && validHostName(rr.hostName()) { existing = prev if !replace { - return nil, nil, fmt.Errorf("already paired with %s — run `privatae-bastion pair --replace ` to rotate its credentials", prev.HostName) + if refusal := sameNameOtherHost(prev, caSHA256(hostCA)); refusal != nil { + return nil, nil, refusal + } + return nil, nil, alreadyPairedRefusal(prev.HostName) } if err := replaceKeepsTransport(existing, transportTCP); err != nil { return nil, nil, err @@ -531,7 +666,7 @@ func pairWithTokenVia(token, redeemURL, hostCAFile string, replace, forceActive if prev := pairingFor(cfgs, rr.HostName); prev != nil { existing = prev if !replace { - return nil, nil, fmt.Errorf("already paired with %s — run `privatae-bastion pair --replace ` to rotate its credentials", prev.HostName) + return nil, nil, alreadyPairedRefusal(prev.HostName) } } opSecret, err := genSecret(24) diff --git a/client-go/pair_code.go b/client-go/pair_code.go new file mode 100644 index 0000000..d4fa946 --- /dev/null +++ b/client-go/pair_code.go @@ -0,0 +1,254 @@ +package main + +// pair_code.go: the pairing code a Host's "Set up a bastion" card hands out, +// so pairing a machine is one pasted line. +// +// privatae-bastion pair skp_… or … | privatae-bastion pair - +// +// The code is the single-use token plus the two things the person used to copy +// by hand: the Host's enroll address and its CA. The CA does not travel; its +// fingerprint does. The bastion takes the CA from the enroll port's own TLS +// chain and keeps it only when its SHA-256 is the one in the code, so the trust +// is the same as `--host-ca`: the fingerprint comes from the owner's own +// signed-in card, as the PEM did. From there the pairing is exactly +// `pair --redeem-url --host-ca `. +// +// No error here repeats the code, for the reason pair_stdin.go gives: a log of +// a failed install must not hold the token. + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" +) + +// pairCodePrefix marks a pairing code; a bare token is skb_…. +const pairCodePrefix = "skp_" + +// pairCodeVersion is the one layout this bastion reads. +const pairCodeVersion = 1 + +// pairCode is a decoded pairing code: where to redeem, the Host CA's +// fingerprint, and the token. +type pairCode struct { + RedeemURL string // "u": the Host's enroll port, https://:/ + CASHA256 string // "ca": SHA-256 of the Host CA certificate's DER, lowercase hex + Token string // "t": the skb_ token +} + +func isPairCode(s string) bool { return strings.HasPrefix(s, pairCodePrefix) } + +// decodePairCode reads skp_: exactly the keys v, u, ca and t, +// v the one version this bastion knows, u an enroll URL parseTCPRedeemURL +// takes, ca 64 lowercase hex digits and t a token decodeToken takes. +func decodePairCode(code string) (pairCode, error) { + var pc pairCode + bad := func(why string) (pairCode, error) { + return pairCode{}, fmt.Errorf("the pairing code is not one this bastion can read (%s); copy the whole line from the card again", why) + } + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(code, pairCodePrefix)) + if err != nil { + return bad("it is not base64url") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return bad("it holds no JSON object") + } + // A map keeps the last of two same-named keys, so "exactly these four" + // is counted on the object as written: a key given twice is an extra key. + if n, err := jsonObjectKeyCount(raw); err != nil || n != len(fields) { + return bad("it names a field twice") + } + if len(fields) != 4 { + return bad(fmt.Sprintf("it has %d fields, and a pairing code has 4", len(fields))) + } + var v int + if err := json.Unmarshal(fields["v"], &v); err != nil { + return bad("its version is missing") + } + if v != pairCodeVersion { + return pairCode{}, fmt.Errorf("the pairing code is version %d, and this bastion reads version %d: update the bastion and pair again", v, pairCodeVersion) + } + for k, dst := range map[string]*string{"u": &pc.RedeemURL, "ca": &pc.CASHA256, "t": &pc.Token} { + if err := json.Unmarshal(fields[k], dst); err != nil || *dst == "" { + return bad("its " + k + " field is missing") + } + } + if _, err := parseTCPRedeemURL(pc.RedeemURL); err != nil { + return bad("its address is not an https enroll address") + } + if len(pc.CASHA256) != 64 || strings.ToLower(pc.CASHA256) != pc.CASHA256 { + return bad("its certificate fingerprint is not 64 lowercase hex digits") + } + if _, err := hex.DecodeString(pc.CASHA256); err != nil { + return bad("its certificate fingerprint is not hex") + } + if !strings.HasPrefix(pc.Token, "skb_") { + return bad("it carries no pairing token") + } + if _, _, err := decodeToken(pc.Token); err != nil { + return bad("its pairing token is malformed") + } + return pc, nil +} + +// jsonObjectKeyCount counts the keys of the JSON object raw as written, +// repeats included. raw has already decoded as an object. +func jsonObjectKeyCount(raw []byte) (int, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + if _, err := dec.Token(); err != nil { // the opening brace + return 0, err + } + n := 0 + for dec.More() { + if _, err := dec.Token(); err != nil { // the key + return 0, err + } + var v json.RawMessage + if err := dec.Decode(&v); err != nil { + return 0, err + } + n++ + } + return n, nil +} + +// pairAnswerTimeout is how long the enroll port has to answer the first +// connection. A Cloud Host's pairing port drops every address but the one the +// card opened it to, so a pair from anywhere else gets no answer at all, and +// without this it would wait for the system's own connect timeout, minutes. +// A variable so a test can shorten it; nothing else assigns it. +var pairAnswerTimeout = 10 * time.Second + +// pairDial dials the enroll port. A variable so a test can stand in a port +// that never answers; nothing else assigns it. +var pairDial = (&net.Dialer{}).DialContext + +// pairWindowError is what a pair says when the Host's enroll port did not +// answer: the plain reason, which is nearly always the pairing window. A type +// of its own so the redeem's HTTP client, which wraps every dial error in the +// request's URL, can hand it on as it is. +type pairWindowError struct{ host string } + +func (e *pairWindowError) Error() string { + return fmt.Sprintf("%s's pairing port didn't answer within %s. A Cloud Host opens it only to the address "+ + "your browser used when you opened \"Set up a bastion\", for 15 minutes. Pair from that network, or "+ + "on the card choose \"Pairing a machine on another network?\" and give this machine's address. Nothing was paired.", + e.host, pairAnswerTimeout) +} + +// dialEnrollPort opens a TCP connection to the redeem URL's host and port +// within pairAnswerTimeout, or says why not in plain words. +func dialEnrollPort(ctx context.Context, u *url.URL) (net.Conn, error) { + addr := u.Host + if u.Port() == "" { + addr = net.JoinHostPort(u.Hostname(), "443") + } + ctx, cancel := context.WithTimeout(ctx, pairAnswerTimeout) + defer cancel() + c, err := pairDial(ctx, "tcp", addr) + if err == nil { + return c, nil + } + var ne net.Error + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &ne) && ne.Timeout()) { + return nil, &pairWindowError{host: u.Hostname()} + } + return nil, fmt.Errorf("could not reach %s's pairing port: %v; nothing was paired", u.Hostname(), err) +} + +// fetchPinnedHostCA takes the Host CA from the enroll port's TLS chain and +// returns it as PEM, only when it is a CA whose SHA-256 over its DER is +// wantSHA and the port's own certificate verifies under it alone for +// tcpServerName, the check hostCATLS makes on every later connection. No +// system roots are consulted at any point. +func fetchPinnedHostCA(u *url.URL, wantSHA string) (string, error) { + conn, err := dialEnrollPort(context.Background(), u) + if err != nil { + return "", err + } + defer conn.Close() + var caPEM string + tc := &tls.Config{ + ServerName: tcpServerName, MinVersion: tls.VersionTLS12, + // The chain is checked by verifyPinnedChain below, against the pinned + // CA alone; the system roots must play no part, so the default + // verification is the thing turned off here. + InsecureSkipVerify: true, + VerifyConnection: func(cs tls.ConnectionState) error { + p, err := verifyPinnedChain(cs.PeerCertificates, wantSHA) + caPEM = p + return err + }, + } + _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) + tlsConn := tls.Client(conn, tc) + if err := tlsConn.Handshake(); err != nil { + return "", fmt.Errorf("%s's pairing port: %v; nothing was paired", u.Hostname(), err) + } + _ = tlsConn.Close() + return caPEM, nil +} + +// verifyPinnedChain picks the pinned CA out of a presented chain and checks +// the leaf against it. The leaf is chain[0]; the CA is any later certificate +// whose SHA-256 is wantSHA. +func verifyPinnedChain(chain []*x509.Certificate, wantSHA string) (string, error) { + if len(chain) == 0 { + return "", errors.New("the Host's pairing port presented no certificate") + } + var ca *x509.Certificate + sawCA := false + for _, c := range chain[1:] { + if !c.IsCA { + continue + } + sawCA = true + sum := sha256.Sum256(c.Raw) + if hex.EncodeToString(sum[:]) == wantSHA { + ca = c + break + } + } + if ca == nil { + if sawCA { + return "", errors.New("the Host's pairing port presented a certificate authority other than the one this pairing code names — refusing") + } + return "", errors.New("the Host's pairing port did not present its certificate authority, so this pairing code cannot be checked — refusing (the Host needs a newer release, or pair with --host-ca)") + } + roots := x509.NewCertPool() + roots.AddCert(ca) + inter := x509.NewCertPool() + for _, c := range chain[1:] { + if c != ca { + inter.AddCert(c) + } + } + if _, err := chain[0].Verify(x509.VerifyOptions{Roots: roots, Intermediates: inter, DNSName: tcpServerName}); err != nil { + return "", fmt.Errorf("the Host's pairing port certificate is not signed by the certificate authority this pairing code names: %v — refusing", err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw})), nil +} + +// caSHA256 is the fingerprint a pairing code carries for a PEM CA: SHA-256 of +// the first certificate's DER, lowercase hex. "" when the PEM holds none. +func caSHA256(caPEM string) string { + certs := pemCerts(caPEM) + if len(certs) == 0 { + return "" + } + sum := sha256.Sum256(certs[0].Raw) + return hex.EncodeToString(sum[:]) +} diff --git a/client-go/pair_code_linux_test.go b/client-go/pair_code_linux_test.go new file mode 100644 index 0000000..e724e14 --- /dev/null +++ b/client-go/pair_code_linux_test.go @@ -0,0 +1,626 @@ +//go:build linux + +// The pairing code a Cloud Host's card hands out (pair_code.go): one string +// carrying the token, the enroll address and the Host CA's fingerprint, and +// the question a local machine asks before it serves a second Host +// (pair_terminal.go). +// +// Uses the fixtures of local_host_linux_test.go and local_host_tcp_linux_test.go +// (newTestCA, pairingBox, startTCPEnrollHost, homeToken), so it carries the same +// build tag. + +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +// makePairCode builds a code the way a Host's card does: skp_ and base64url, +// no padding, of the JSON object fields. +func makePairCode(t *testing.T, fields map[string]any) string { + t.Helper() + raw, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + return pairCodePrefix + base64.RawURLEncoding.EncodeToString(raw) +} + +func pairCodeFingerprint(ca *testCA) string { + sum := sha256.Sum256(ca.cert.Raw) + return hex.EncodeToString(sum[:]) +} + +// chainWithCA is the enroll port's certificate as a Host from this release +// serves it: the leaf, then the CA. +func chainWithCA(t *testing.T, ca *testCA) tls.Certificate { + t.Helper() + c := ca.serverCert(t, tcpServerName) + c.Certificate = append(c.Certificate, ca.cert.Raw) + return c +} + +func goodCodeFields(url, fp string) map[string]any { + return map[string]any{"v": 1, "u": url, "ca": fp, "t": homeToken()} +} + +// ── decoding ──────────────────────────────────────────────────────────────── + +func TestAPairingCodeDecodes(t *testing.T) { + fp := strings.Repeat("ab", 32) + code := makePairCode(t, goodCodeFields("https://maple.example-id.cloud.example:8810/v1/local/redeem", fp)) + pc, err := decodePairCode(code) + if err != nil { + t.Fatal(err) + } + if pc.RedeemURL != "https://maple.example-id.cloud.example:8810/v1/local/redeem" || pc.CASHA256 != fp || pc.Token != homeToken() { + t.Fatalf("decoded: %+v", pc) + } + token, opts, err := pairOptsFor(code, "", "", false) + if err != nil || token != homeToken() || opts.redeemURL != pc.RedeemURL || opts.caSHA256 != fp || opts.hostCAFile != "" || opts.askSecond == nil { + t.Fatalf("pair's options from a code: %q %+v %v", token, opts, err) + } + if _, _, err := pairOptsFor(code, "", "host-ca.pem", false); err == nil { + t.Fatal("a code carries its own certificate: --host-ca beside it must be refused") + } + if tok, opts, err := pairOptsFor(homeToken(), "", "", false); err != nil || tok != homeToken() || opts.caSHA256 != "" { + t.Fatalf("a bare token passes through as it was: %q %+v %v", tok, opts, err) + } +} + +// A code this bastion cannot read is refused with what was wrong, never +// with its content: a log of a failed install must not hold the token. +func TestAPairingCodeIsReadStrictly(t *testing.T) { + fp := strings.Repeat("0f", 32) + u := "https://maple.example-id.cloud.example:8810/v1/local/redeem" + with := func(edit func(m map[string]any)) string { + m := goodCodeFields(u, fp) + edit(m) + return makePairCode(t, m) + } + for name, code := range map[string]string{ + "garbled": "skp_%%%not-base64%%%", + "not JSON": pairCodePrefix + base64.RawURLEncoding.EncodeToString([]byte("secret-x home")), + "wrong version": with(func(m map[string]any) { m["v"] = 2 }), + "version as text": with(func(m map[string]any) { m["v"] = "1" }), + "extra key": with(func(m map[string]any) { m["x"] = "y" }), + "missing key": with(func(m map[string]any) { delete(m, "ca") }), + "another key": with(func(m map[string]any) { delete(m, "t"); m["token"] = homeToken() }), + "http address": with(func(m map[string]any) { m["u"] = "http://maple.example-id.cloud.example:8810/v1/local/redeem" }), + "address with user": with(func(m map[string]any) { m["u"] = "https://a@maple.example-id.cloud.example/v1/local/redeem" }), + "short fingerprint": with(func(m map[string]any) { m["ca"] = fp[:63] }), + "upper fingerprint": with(func(m map[string]any) { m["ca"] = strings.ToUpper(fp) }), + "not hex": with(func(m map[string]any) { m["ca"] = strings.Repeat("zz", 32) }), + "no skb_ token": with(func(m map[string]any) { m["t"] = "secret-x" }), + "malformed token": with(func(m map[string]any) { m["t"] = "skb_bm9kb3Q" }), // base64url("nodot") + } { + _, err := decodePairCode(code) + if err == nil { + t.Errorf("%s: refused nothing", name) + continue + } + if strings.Contains(err.Error(), "secret-x") || strings.Contains(err.Error(), homeToken()) { + t.Errorf("%s: the refusal repeats the token: %v", name, err) + } + } + _, err := decodePairCode(with(func(m map[string]any) { m["v"] = 2 })) + if err == nil || !strings.Contains(err.Error(), "update the bastion") { + t.Fatalf("a newer code says to update the bastion: %v", err) + } +} + +// ── the pinned CA ─────────────────────────────────────────────────────────── + +// SECURITY: the CA is taken from the enroll port's chain only when its +// SHA-256 is the code's, and the port's own certificate must verify under it +// for tcpServerName. A chain without the CA, another CA, or a leaf another CA +// signed are refused before the token is spent, and nothing is saved. +func TestAPairingCodePinsTheHostCAByFingerprint(t *testing.T) { + ca := newTestCA(t, "maple host CA") + impostor := newTestCA(t, "impostor") + leafByImpostor := impostor.serverCert(t, tcpServerName) + leafByImpostor.Certificate = append(leafByImpostor.Certificate, ca.cert.Raw) + for name, c := range map[string]struct { + serve tls.Certificate + fp string + want string + }{ + "no CA in the chain": {ca.serverCert(t, tcpServerName), pairCodeFingerprint(ca), "did not present"}, + "another CA in the chain": {chainWithCA(t, impostor), pairCodeFingerprint(ca), "other than"}, + "another fingerprint": {chainWithCA(t, ca), pairCodeFingerprint(impostor), "other than"}, + "a leaf another CA signed": {leafByImpostor, pairCodeFingerprint(ca), "not signed by"}, + "the CA, under another name": {func() tls.Certificate { + c := ca.serverCert(t, "maple.example") + c.Certificate = append(c.Certificate, ca.cert.Raw) + return c + }(), pairCodeFingerprint(ca), "not signed by"}, + } { + t.Run(name, func(t *testing.T) { + pairingBox(t) + h := startTCPEnrollHost(t, ca, c.serve, "127.0.0.1:18821") + token, opts, err := pairOptsFor(makePairCode(t, goodCodeFields(h.url, c.fp)), "", "", false) + if err != nil { + t.Fatal(err) + } + _, _, err = pairWith(token, opts) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Fatalf("want a refusal naming %q, got %v", c.want, err) + } + if h.hits.Load() != 0 { + t.Fatal("the token went to an enroll port whose CA was never checked") + } + if cfgs, _ := loadPairings(); len(cfgs) != 0 { + t.Fatalf("a refused pairing saved %d pairings", len(cfgs)) + } + }) + } +} + +// A good code pairs exactly as --redeem-url u --host-ca t does: the +// same TCP pairing, the same CA pinned. +func TestAPairingCodePairsAsTheHostCAFileDoes(t *testing.T) { + pairingBox(t) + ca := newTestCA(t, "maple host CA") + h := startTCPEnrollHost(t, ca, chainWithCA(t, ca), "127.0.0.1:18822") + token, opts, err := pairOptsFor(makePairCode(t, goodCodeFields(h.url, pairCodeFingerprint(ca))), "", "", false) + if err != nil { + t.Fatal(err) + } + cfg, _, err := pairWith(token, opts) + if err != nil { + t.Fatal(err) + } + if !cfg.tcpTransport() || !sameCAs(cfg.CACert, ca.pem) || cfg.LaneAddr != "127.0.0.1:18822" || cfg.HostName != "home.example.test" { + t.Fatalf("the pairing: transport %q lane %q host %q", cfg.Transport, cfg.LaneAddr, cfg.HostName) + } + h.mu.Lock() + req, sni := h.last, h.sni + h.mu.Unlock() + if req.Secret != "secret-x" || req.Handle != "home" || sni != tcpServerName { + t.Fatalf("the redeem is today's: secret %q handle %q sni %q", req.Secret, req.Handle, sni) + } + + // The same code again: already paired with this Host, nothing changed, + // and the error is the one `pair` exits 3 on. + before, _ := os.ReadFile(filepath.Join(mustPairingsDir(t), "home.example.test.json")) + token, opts, _ = pairOptsFor(makePairCode(t, goodCodeFields(h.url, pairCodeFingerprint(ca))), "", "", false) + _, _, err = pairWith(token, opts) + var already *alreadyPairedError + if !errors.As(err, &already) { + t.Fatalf("pairing the same Host again must be alreadyPairedError (exit %d), got %v", pairExitAlreadyPaired, err) + } + after, _ := os.ReadFile(filepath.Join(mustPairingsDir(t), "home.example.test.json")) + if string(before) != string(after) || h.hits.Load() != 1 { + t.Fatal("an already-paired Host must change nothing and spend no token") + } + + // Another Host of the same name (Cloud names repeat): a different CA, so + // never "already connected". + other := newTestCA(t, "another maple") + h2 := startTCPEnrollHost(t, other, chainWithCA(t, other), "127.0.0.1:18823") + token, opts, _ = pairOptsFor(makePairCode(t, goodCodeFields(h2.url, pairCodeFingerprint(other))), "", "", false) + _, _, err = pairWith(token, opts) + if err == nil || errors.As(err, &already) || !strings.Contains(err.Error(), "different Host named") { + t.Fatalf("a same-named Host under another CA is a plain refusal, not exit 3: %v", err) + } +} + +func mustPairingsDir(t *testing.T) string { + t.Helper() + d, err := pairingsDir() + if err != nil { + t.Fatal(err) + } + return d +} + +// ── an enroll port that does not answer ───────────────────────────────────── + +// A Cloud Host's pairing port drops every address but the one the card opened +// it to. A pair from elsewhere says so within pairAnswerTimeout, in plain +// words, instead of waiting for the system's connect timeout. Both the code +// and --host-ca paths. +func TestAnEnrollPortThatDoesNotAnswerNamesThePairingWindow(t *testing.T) { + pairingBox(t) + oldTimeout, oldDial := pairAnswerTimeout, pairDial + t.Cleanup(func() { pairAnswerTimeout, pairDial = oldTimeout, oldDial }) + pairAnswerTimeout = 150 * time.Millisecond + pairDial = func(ctx context.Context, _, _ string) (net.Conn, error) { + <-ctx.Done() + return nil, ctx.Err() + } + u := "https://maple.example-id.cloud.example:8810/v1/local/redeem" + ca := newTestCA(t, "maple host CA") + check := func(name string, err error, took time.Duration) { + t.Helper() + var we *pairWindowError + if !errors.As(err, &we) { + t.Fatalf("%s: want the pairing-window message, got %v", name, err) + } + msg := err.Error() + if !strings.Contains(msg, "maple.example-id.cloud.example's pairing port didn't answer") || + !strings.Contains(msg, "15 minutes") || !strings.Contains(msg, "another network") || !strings.Contains(msg, "Nothing was paired") { + t.Fatalf("%s: the message: %s", name, msg) + } + if took > 2*time.Second { + t.Fatalf("%s: took %s, the timeout is %s", name, took, pairAnswerTimeout) + } + } + start := time.Now() + token, opts, _ := pairOptsFor(makePairCode(t, goodCodeFields(u, pairCodeFingerprint(ca))), "", "", false) + _, _, err := pairWith(token, opts) + check("pairing code", err, time.Since(start)) + + start = time.Now() + _, _, err = pairWithTokenVia(homeToken(), u, writeCA(t, ca), false, false) + check("--host-ca", err, time.Since(start)) +} + +// ── a local machine that already serves a Host ────────────────────────────── + +// fakeTerminal stands in a person at this machine: their answer, and what +// the question said. answer "" with noTerm set is a run with no terminal. +type fakeTerminal struct { + answer string + noTerm bool + asked strings.Builder + opened int +} + +func (f *fakeTerminal) use(t *testing.T) { + t.Helper() + old := openTerminal + t.Cleanup(func() { openTerminal = old }) + openTerminal = func() (io.Reader, io.Writer, func(), error) { + f.opened++ + if f.noTerm { + return nil, nil, nil, errNoTerminal + } + return strings.NewReader(f.answer), &f.asked, func() {}, nil + } +} + +func otherToken() string { return "skb_b3RoZXIuc2VjcmV0LXk" } // base64url("other.secret-y") + +// SECURITY: a local machine that serves a Host adds another only on a yes +// typed at this machine's terminal. No terminal, or any answer but y or yes, +// refuses before the token is spent, and the first pairing is untouched. +// Every caller but `pair` (claim-anchor, the pairing op) keeps the old +// refusal: it has no person to ask. +func TestALocalMachineAddsASecondHostOnlyOnAYesTypedHere(t *testing.T) { + pairingBox(t) + ca := newTestCA(t, "home host CA") + home := startTCPEnrollHost(t, ca, ca.serverCert(t, tcpServerName), "127.0.0.1:18831") + if _, _, err := pairWithTokenVia(homeToken(), home.url, writeCA(t, ca), false, false); err != nil { + t.Fatal(err) + } + first, _ := os.ReadFile(filepath.Join(mustPairingsDir(t), "home.example.test.json")) + + otherCA := newTestCA(t, "other host CA") + other := startTCPEnrollHost(t, otherCA, chainWithCA(t, otherCA), "127.0.0.1:18832") + other.set(func(h *tcpEnrollHost) { + h.edit = func(ans map[string]any) { ans["host_handle"], ans["host_name"] = "other", "other.example.test" } + }) + code := makePairCode(t, map[string]any{"v": 1, "u": other.url, "ca": pairCodeFingerprint(otherCA), "t": otherToken()}) + pairOther := func() error { + token, opts, err := pairOptsFor(code, "", "", false) + if err != nil { + t.Fatal(err) + } + _, _, err = pairWith(token, opts) + return err + } + untouched := func(why string) { + t.Helper() + now, _ := os.ReadFile(filepath.Join(mustPairingsDir(t), "home.example.test.json")) + cfgs, _ := loadPairings() + if string(now) != string(first) { + t.Fatalf("%s: the first pairing changed", why) + } + if why != "yes" && (len(cfgs) != 1 || other.hits.Load() != 0) { + t.Fatalf("%s: %d pairings, %d redeems", why, len(cfgs), other.hits.Load()) + } + } + + // No terminal: refused, and it says where to run it instead. + ft := &fakeTerminal{noTerm: true} + ft.use(t) + if err := pairOther(); err == nil || !strings.Contains(err.Error(), "terminal window on this computer") { + t.Fatalf("no terminal must refuse in plain words: %v", err) + } + untouched("no terminal") + + // Anything but y or yes. + for _, a := range []string{"\n", "n\n", "no\n", "yess\n", "sure\n", ""} { + ft := &fakeTerminal{answer: a} + ft.use(t) + if err := pairOther(); err == nil || !strings.Contains(err.Error(), "stays the only Host") { + t.Fatalf("answer %q must refuse: %v", a, err) + } + if !strings.Contains(ft.asked.String(), "already serves your Host home.example.test. Add other as well?") { + t.Fatalf("the question: %q", ft.asked.String()) + } + untouched("answer " + a) + } + + // The callers with no person to ask keep the refusal, and ask nobody. + ft = &fakeTerminal{answer: "y\n"} + ft.use(t) + var second *localSecondHostError + if _, _, err := pairWithTokenVia(otherToken(), other.url, writeCA(t, otherCA), false, false); !errors.As(err, &second) { + t.Fatalf("pairWithTokenVia (no person to ask) must keep the refusal: %v", err) + } + if ft.opened != 0 { + t.Fatal("a caller with no person to ask opened the terminal") + } + untouched("no asker") + + // A typed yes adds it beside the first. + ft = &fakeTerminal{answer: "Yes\n"} + ft.use(t) + if err := pairOther(); err != nil { + t.Fatal(err) + } + cfgs, _ := loadPairings() + if len(cfgs) != 2 || pairingFor(cfgs, "other.example.test") == nil { + t.Fatalf("after yes: %d pairings", len(cfgs)) + } + untouched("yes") +} + +// secondHostStdinChild set to 1 makes this test binary the child of +// TestTheSecondHostAnswerIsNeverReadFromStandardInput. +const secondHostStdinChild = "PRIVATAE_TEST_SECOND_HOST_STDIN_CHILD" + +// SECURITY: the answer to the second-Host question comes from the controlling +// terminal, never from standard input. The installers hand the pairing code to +// `pair -` on standard input, so a "y" piped in after the code must not add a +// Host. The real openTerminal runs in a child with no controlling terminal (a +// session of its own) and "y" waiting on its standard input: it must find no +// terminal, and leave standard input unread. +func TestTheSecondHostAnswerIsNeverReadFromStandardInput(t *testing.T) { + if os.Getenv(secondHostStdinChild) == "1" { + yes, err := confirmAddHost("home.example.test", "other") + rest, _ := io.ReadAll(os.Stdin) + fmt.Printf("child: yes=%v refused=%v stdin-left=%q\n", yes, err != nil && strings.Contains(err.Error(), "no terminal"), rest) + os.Exit(0) + } + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(self, "-test.run=^TestTheSecondHostAnswerIsNeverReadFromStandardInput$") + cmd.Env = append(os.Environ(), secondHostStdinChild+"=1") + cmd.Stdin = strings.NewReader("y\n") + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("the child: %v\n%s", err, out) + } + if !strings.Contains(string(out), `child: yes=false refused=true stdin-left="y\n"`) { + t.Fatalf("a y on standard input must neither answer the question nor be read: %s", out) + } +} + +// The refusal names this system's own way to a networked bastion, and says +// that the Host already here would have to be paired again. +func TestTheLocalRefusalSaysWhatANetworkedReinstallCosts(t *testing.T) { + msg := localOneHostRefusal("home.example.test") + if !strings.Contains(msg, "install.sh --networked") || strings.Contains(msg, "install.ps1") { + t.Fatalf("on linux it names install.sh: %s", msg) + } + if !strings.Contains(msg, "home.example.test has to be paired with it again") || !strings.Contains(msg, "answer yes at this computer's terminal") { + t.Fatalf("the refusal: %s", msg) + } +} + +// ── what the Host's card actually makes ───────────────────────────────────── + +// Made by the C-Series Host's own pairing_code (bastion_enroll.py) from the CA +// below and an invented token, for an invented address: compact JSON with the +// keys v, u, ca and t in base64url without padding. The bastion must read +// exactly that, and the fingerprint must be the one caSHA256 computes from +// the same CA, or a real code would never pin. +const hostMadePairCode = "skp_eyJ2IjoxLCJ1IjoiaHR0cHM6Ly9tYXBsZS5leGFtcGxlLWlkLmNsb3VkLmV4YW1wbGU6ODgxMC92MS9sb2NhbC9yZWRlZW0iLCJjYSI6IjcyMzNkOWU4ODU5MzIyZjE1YTJiNmMzYWIyMmZiNjMxNGI4M2I0Njg2ZmNhYmZiYjRmNGU1NDE5NDA1MTFkMDciLCJ0Ijoic2tiX2JXRndiR1V1YzJWamNtVjBMV2x1ZG1WdWRHVmtMVEF3TURFIn0" + +const hostMadePairCodeCA = `-----BEGIN CERTIFICATE----- +MIIBTTCB9KADAgECAgIQkjAKBggqhkjOPQQDAjAjMSEwHwYDVQQDDBhleGFtcGxl +IGludmVudGVkIGhvc3QgQ0EwIBcNMjYwMTAxMDAwMDAwWhgPMjEyNTEyMDgwMDAw +MDBaMCMxITAfBgNVBAMMGGV4YW1wbGUgaW52ZW50ZWQgaG9zdCBDQTBZMBMGByqG +SM49AgEGCCqGSM49AwEHA0IABA32JMrh4sMMLvNNoSBq5FgXFgHd/2n71Qk9ayje +Kj9rdt+Q3CFYWgtCqFHc6ZLUfXexKkdWvnET3px2hGqZWbKjFjAUMBIGA1UdEwEB +/wQIMAYBAf8CAQAwCgYIKoZIzj0EAwIDSAAwRQIhAIQ6sKtiqxcAQTPq7+toiEl0 +qrWb4CKKB2UtvcQoC9YwAiBYGKbmvG9ZszE8X7uKUGexjP9x6lDOwqfmnLoruuVg +Kg== +-----END CERTIFICATE----- +` + +func TestACodeTheHostMadeDecodesAndPins(t *testing.T) { + pc, err := decodePairCode(hostMadePairCode) + if err != nil { + t.Fatal(err) + } + if pc.RedeemURL != "https://maple.example-id.cloud.example:8810/v1/local/redeem" || + pc.Token != "skb_bWFwbGUuc2VjcmV0LWludmVudGVkLTAwMDE" { // base64url("maple.secret-invented-0001") + t.Fatalf("decoded: %+v", pc) + } + if got := caSHA256(hostMadePairCodeCA); got != pc.CASHA256 { + t.Fatalf("the Host's fingerprint %s is not caSHA256 of its CA (%s)", pc.CASHA256, got) + } + if h, _, err := decodeToken(pc.Token); err != nil || h != "maple" { + t.Fatalf("the token's handle: %q %v", h, err) + } +} + +// SECURITY: a key given twice is an extra key. A map would keep only the +// last, so two codes that differ in which token they carry could both read as +// "exactly v, u, ca and t". +func TestAPairingCodeWithAKeyGivenTwiceIsRefused(t *testing.T) { + fp := strings.Repeat("0f", 32) + u := "https://maple.example-id.cloud.example:8810/v1/local/redeem" + raw := `{"v":1,"u":"` + u + `","ca":"` + fp + `","t":"` + otherToken() + `","t":"` + homeToken() + `"}` + if _, err := decodePairCode(pairCodePrefix + base64.RawURLEncoding.EncodeToString([]byte(raw))); err == nil { + t.Fatal("a code naming t twice was read") + } + once := `{"v":1,"u":"` + u + `","ca":"` + fp + `","t":"` + homeToken() + `"}` + if _, err := decodePairCode(pairCodePrefix + base64.RawURLEncoding.EncodeToString([]byte(once))); err != nil { + t.Fatalf("the same code with t once: %v", err) + } +} + +// pinnedChainCert is a certificate for the pinned-chain cases: signed by +// parent (self-signed when parent is nil), a CA or not, valid from nb to na. +func pinnedChainCert(t *testing.T, cn string, isCA bool, nb, na time.Time, parent *x509.Certificate, parentKey *ecdsa.PrivateKey, dns ...string) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: cn}, + NotBefore: nb, NotAfter: na, DNSNames: dns, BasicConstraintsValid: true, IsCA: isCA} + if isCA { + tmpl.KeyUsage = x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature + } else { + tmpl.KeyUsage, tmpl.ExtKeyUsage = x509.KeyUsageDigitalSignature, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} + } + signer, signKey := tmpl, key + if parent != nil { + signer, signKey = parent, parentKey + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, signer, &key.PublicKey, signKey) + if err != nil { + t.Fatal(err) + } + c, _ := x509.ParseCertificate(der) + return c, key +} + +// SECURITY: the cases of the pinned chain the TLS tests do not reach. A +// certificate with the code's fingerprint that is not a CA, and a CA that has +// expired, are refused; the pinned CA anywhere after the leaf, beside other +// certificates, is the one returned. +func TestThePinnedChainTakesOnlyAValidCAWithTheFingerprint(t *testing.T) { + now := time.Now() + fpOf := func(c *x509.Certificate) string { s := sha256.Sum256(c.Raw); return hex.EncodeToString(s[:]) } + + ca, caKey := pinnedChainCert(t, "maple host CA", true, now.Add(-time.Hour), now.Add(24*time.Hour), nil, nil) + leaf, _ := pinnedChainCert(t, "leaf", false, now.Add(-time.Hour), now.Add(time.Hour), ca, caKey, tcpServerName) + other, _ := pinnedChainCert(t, "another CA", true, now.Add(-time.Hour), now.Add(24*time.Hour), nil, nil) + got, err := verifyPinnedChain([]*x509.Certificate{leaf, other, ca}, fpOf(ca)) + if err != nil || caSHA256(got) != fpOf(ca) { + t.Fatalf("the pinned CA after another certificate: %v", err) + } + + // A self-signed certificate that is not a CA, signing the leaf. + notCA, notCAKey := pinnedChainCert(t, "not a CA", false, now.Add(-time.Hour), now.Add(24*time.Hour), nil, nil) + leaf2, _ := pinnedChainCert(t, "leaf", false, now.Add(-time.Hour), now.Add(time.Hour), notCA, notCAKey, tcpServerName) + if _, err := verifyPinnedChain([]*x509.Certificate{leaf2, notCA}, fpOf(notCA)); err == nil { + t.Fatal("a certificate with the fingerprint that is not a CA was pinned") + } + + // An expired CA, with a leaf it signed that is still in its dates. + old, oldKey := pinnedChainCert(t, "expired host CA", true, now.Add(-48*time.Hour), now.Add(-time.Hour), nil, nil) + leaf3, _ := pinnedChainCert(t, "leaf", false, now.Add(-2*time.Hour), now.Add(time.Hour), old, oldKey, tcpServerName) + if _, err := verifyPinnedChain([]*x509.Certificate{leaf3, old}, fpOf(old)); err == nil { + t.Fatal("an expired CA was pinned") + } + + if _, err := verifyPinnedChain([]*x509.Certificate{leaf}, fpOf(ca)); err == nil { + t.Fatal("a chain of the leaf alone was accepted") + } +} + +// Exit 3 means the same Host, so the same CA, on every TCP pairing: a +// --host-ca pair to a Host of a name already paired here under another CA is +// a plain failure, as the pairing code's is. +func TestHostCAFileWithASameNamedHostUnderAnotherCAIsNotAlreadyPaired(t *testing.T) { + pairingBox(t) + ca := newTestCA(t, "home host CA") + h := startTCPEnrollHost(t, ca, ca.serverCert(t, tcpServerName), "127.0.0.1:18841") + if _, _, err := pairWithTokenVia(homeToken(), h.url, writeCA(t, ca), false, false); err != nil { + t.Fatal(err) + } + var already *alreadyPairedError + if _, _, err := pairWithTokenVia(homeToken(), h.url, writeCA(t, ca), false, false); !errors.As(err, &already) { + t.Fatalf("the same Host under its own CA is already paired (exit 3): %v", err) + } + other := newTestCA(t, "another home") + h2 := startTCPEnrollHost(t, other, other.serverCert(t, tcpServerName), "127.0.0.1:18842") + _, _, err := pairWithTokenVia(homeToken(), h2.url, writeCA(t, other), false, false) + if err == nil || errors.As(err, &already) || !strings.Contains(err.Error(), "different Host named") { + t.Fatalf("a same-named Host under another CA is a plain refusal, not exit 3: %v", err) + } + if h2.hits.Load() != 0 { + t.Fatal("the refusal spent the other Host's token") + } +} + +// SECURITY: the name in the question is the token's handle, which whoever +// made the token chose. A control character or escape sequence in it reaches +// the person's screen escaped, so it cannot rewrite the question. +func TestTheSecondHostQuestionEscapesTheTokensName(t *testing.T) { + ft := &fakeTerminal{answer: "n\n"} + ft.use(t) + if _, err := confirmAddHost("home.example.test", "evil\r\x1b[2K\x1b[1Ainnocent"); err != nil { + t.Fatal(err) + } + q := ft.asked.String() + if strings.ContainsAny(q, "\x1b\r") { + t.Fatalf("the question carries the handle's control bytes raw: %q", q) + } + if !strings.Contains(q, `Add "evil\r\x1b[2K\x1b[1Ainnocent" as well?`) { + t.Fatalf("the question: %q", q) + } + ft = &fakeTerminal{answer: "n\n"} + ft.use(t) + if _, err := confirmAddHost("home.example.test", "maple"); err != nil || !strings.Contains(ft.asked.String(), "Add maple as well?") { + t.Fatalf("a plain name is shown as it is: %q %v", ft.asked.String(), err) + } +} + +// The name the redeem answers with is checked again after the redeem (the +// token's handle can differ from it). There too, the same name under another +// CA is a plain failure, never exit 3. +func TestARedeemAnsweringAPairedNameUnderAnotherCAIsNotAlreadyPaired(t *testing.T) { + pairingBox(t) + ca := newTestCA(t, "home host CA") + h := startTCPEnrollHost(t, ca, ca.serverCert(t, tcpServerName), "127.0.0.1:18843") + if _, _, err := pairWithTokenVia(homeToken(), h.url, writeCA(t, ca), false, false); err != nil { + t.Fatal(err) + } + other := newTestCA(t, "another home") + h2 := startTCPEnrollHost(t, other, chainWithCA(t, other), "127.0.0.1:18844") + // otherToken's handle is "other"; the Host answers home.example.test. + token, opts, err := pairOptsFor(makePairCode(t, map[string]any{"v": 1, "u": h2.url, "ca": pairCodeFingerprint(other), "t": otherToken()}), "", "", false) + if err != nil { + t.Fatal(err) + } + opts.askSecond = func(string, string) (bool, error) { return true, nil } + _, _, err = pairWith(token, opts) + var already *alreadyPairedError + if err == nil || errors.As(err, &already) || !strings.Contains(err.Error(), "different Host named home.example.test") { + t.Fatalf("a redeem naming a paired Host under another CA is a plain refusal, not exit 3: %v", err) + } + if cfgs, _ := loadPairings(); len(cfgs) != 1 { + t.Fatalf("%d pairings after the refusal", len(cfgs)) + } +} diff --git a/client-go/pair_tcp.go b/client-go/pair_tcp.go index b4092c9..d2df83c 100644 --- a/client-go/pair_tcp.go +++ b/client-go/pair_tcp.go @@ -149,11 +149,19 @@ func redeemOverTCP(u *url.URL, caPEM string, body []byte) (tcpRedeemResp, error) if err != nil { return rr, fmt.Errorf("--host-ca: %w", err) } + // The dial is dialEnrollPort's: an enroll port that does not answer in + // pairAnswerTimeout gets the pairing window's plain message, not a wait + // for the system's connect timeout. httpc := &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{ Proxy: nil, TLSClientConfig: tc, ForceAttemptHTTP2: false, + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return dialEnrollPort(ctx, u) }, }, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := httpc.Post(u.String(), "application/json", bytes.NewReader(body)) if err != nil { + var we *pairWindowError + if errors.As(err, &we) { + return rr, we + } return rr, fmt.Errorf("redeem: %w", err) } defer resp.Body.Close() diff --git a/client-go/pair_terminal.go b/client-go/pair_terminal.go new file mode 100644 index 0000000..049ebcb --- /dev/null +++ b/client-go/pair_terminal.go @@ -0,0 +1,66 @@ +package main + +// pair_terminal.go: the one question `pair` asks a person, on a local machine +// that already serves a Host: whether to add another. +// +// A local bastion used to serve one Host, full stop, because on a single-user +// machine a second Host appearing meant someone else had got a token onto the +// box. A person who uses two Hosts (a Cloud machine beside the platform one) +// could not pair the second at all. Now the second is added when the person at +// this machine says so, and only then: the answer is read from the controlling +// terminal, never from standard input, so a token piped in by a script or an +// installer (which feeds the pairing code through standard input) cannot add a +// Host by itself. With no terminal there is nobody to ask, and the pair is +// refused. + +import ( + "bufio" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +// errNoTerminal: this process has no controlling terminal to ask on. +var errNoTerminal = errors.New("no terminal") + +// openTerminal opens the controlling terminal for one question: a reader for +// the answer, a writer for the question, and a close. A variable so a test can +// stand in a person; nothing else assigns it. +var openTerminal = openControllingTerminal + +// terminalSafeName is a Host name as the question shows it. newHost is the +// handle the token carries, which nothing has checked yet: whoever made the +// token chose it, and a control character or an escape sequence in it could +// rewrite the question on the person's screen. A plain host name is shown as +// it is; anything else is quoted, every such byte escaped. +func terminalSafeName(name string) string { + if validHostName(name) { + return name + } + return strconv.QuoteToASCII(name) +} + +// confirmAddHost asks whether to add newHost beside serving, the Host this +// local machine already serves. True only for a typed y or yes. +func confirmAddHost(serving, newHost string) (bool, error) { + in, out, closeTerm, err := openTerminal() + if err != nil { + return false, fmt.Errorf("this computer already serves your Host %s, and adding another needs a yes typed "+ + "at this computer, but this run has no terminal to ask in. Run the same command in a terminal "+ + "window on this computer; nothing was paired", serving) + } + defer closeTerm() + fmt.Fprintf(out, "This computer already serves your Host %s. Add %s as well? It gets a pairing of its own,\n"+ + "and `privatae-bastion ui` shows what each Host may do here. [y/N] ", terminalSafeName(serving), terminalSafeName(newHost)) + line, err := bufio.NewReader(io.LimitReader(in, 256)).ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return false, fmt.Errorf("could not read the answer (%v); nothing was paired", err) + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true, nil + } + return false, nil +} diff --git a/client-go/pair_terminal_unix.go b/client-go/pair_terminal_unix.go new file mode 100644 index 0000000..674ee5b --- /dev/null +++ b/client-go/pair_terminal_unix.go @@ -0,0 +1,19 @@ +//go:build !windows + +package main + +import ( + "io" + "os" +) + +// openControllingTerminal opens /dev/tty, this process's controlling terminal +// whatever its standard input is. It fails with no terminal (a service, a +// pipe from `ssh host cmd` without -t, cron). +func openControllingTerminal() (io.Reader, io.Writer, func(), error) { + f, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) + if err != nil { + return nil, nil, nil, errNoTerminal + } + return f, f, func() { _ = f.Close() }, nil +} diff --git a/client-go/pair_terminal_windows.go b/client-go/pair_terminal_windows.go new file mode 100644 index 0000000..f69caab --- /dev/null +++ b/client-go/pair_terminal_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package main + +import ( + "io" + "os" +) + +// openControllingTerminal opens the console this process is attached to, +// CONIN$ for the answer and CONOUT$ for the question, whatever its standard +// input is: install.ps1 pipes the pairing code into `pair -`, and the person +// still answers in the PowerShell window. It fails with no console (a +// service, a scheduled task with no window). +func openControllingTerminal() (io.Reader, io.Writer, func(), error) { + in, err := os.OpenFile("CONIN$", os.O_RDWR, 0) + if err != nil { + return nil, nil, nil, errNoTerminal + } + out, err := os.OpenFile("CONOUT$", os.O_RDWR, 0) + if err != nil { + _ = in.Close() + return nil, nil, nil, errNoTerminal + } + return in, out, func() { _ = in.Close(); _ = out.Close() }, nil +} diff --git a/client-go/service.go b/client-go/service.go index 9ea82fb..bd18232 100644 --- a/client-go/service.go +++ b/client-go/service.go @@ -288,17 +288,40 @@ func serviceArgs(spec harnessSpec) []string { // serviceOwnerRefusal: on an anchored box a member pairing may not replace or // stop a slot another host built. The anchor pairing may (its user is the -// machine's admin), and an unanchored local box serves one host. +// machine's admin). A local box has no anchor to be that admin, and serves a +// second host only on its person's yes (pair_terminal.go); once it serves two, +// each host changes only the slots it built. With one host there is nobody to +// keep apart (a host renamed since it built a slot still owns it), and +// pairings that cannot be read are treated as two. func serviceOwnerRefusal(self string, r *serviceRecord) string { if r == nil || r.Owner == "" || r.Owner == self { return "" } + if boxMode() == modeLocal { + if !localServesSeveralHosts() { + return "" + } + return "refused: slot '" + r.Name + "' was built by " + r.Owner + "; on a local machine that serves several Hosts each changes only the servers it built" + } if why := anchorOnly(self, "changing a server another host built"); why != "" { return "refused: slot '" + r.Name + "' was built by " + r.Owner + "; " + why } return "" } +// localServesSeveralHosts: this is a local machine that serves more than one +// Host (the second added on its person's yes, pair_terminal.go), so what one +// Host built or started here is kept from the others. Pairings that cannot be +// read count as several. False on a networked machine, whose anchor rule +// (anchorOnly) keeps its Hosts apart. +func localServesSeveralHosts() bool { + if boxMode() != modeLocal { + return false + } + cfgs, err := loadPairings() + return err != nil || len(cfgs) > 1 +} + func serviceFail(res map[string]interface{}, msg string) ([]byte, int) { res["ok"], res["error"] = false, msg b, _ := json.Marshal(res) diff --git a/client-go/service_test.go b/client-go/service_test.go index 9c02ad0..8eec96f 100644 --- a/client-go/service_test.go +++ b/client-go/service_test.go @@ -495,10 +495,24 @@ func TestServiceOwnerRuleOnAnAnchoredBox(t *testing.T) { if why := serviceOwnerRefusal(tMember, &serviceRecord{Name: "forge", Owner: tMember}); why != "" { t.Fatalf("the builder may change its own server: %s", why) } - // Local box: one host, no owner rule to apply. + // Local box with one host: nobody to keep apart, as before. t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := (&Config{HostName: tMember, ListenAddr: "127.0.0.1:7878"}).save(); err != nil { + t.Fatal(err) + } if why := serviceOwnerRefusal(tMember, r); why != "" { - t.Fatalf("a local bastion serves one host: %s", why) + t.Fatalf("a local machine with one host applies no owner rule: %s", why) + } + // Local box serving a second host (added on its person's yes): each host + // changes only the servers it built. + if err := (&Config{HostName: tAnchor, ListenAddr: "127.0.0.1:7879"}).save(); err != nil { + t.Fatal(err) + } + if why := serviceOwnerRefusal(tMember, r); why == "" || !strings.Contains(why, "built by "+tAnchor) { + t.Fatalf("on a local machine serving two hosts one must not change the other's server: %q", why) + } + if why := serviceOwnerRefusal(tMember, &serviceRecord{Name: "forge", Owner: tMember}); why != "" { + t.Fatalf("on a local machine a host changes its own server: %s", why) } } @@ -852,3 +866,28 @@ func TestSpliceUnixCarriesBothWays(t *testing.T) { } _ = a.Close() } + +// A coder run on a local machine that serves two Hosts is stopped only by +// the Host that started it, as a server is changed only by the Host that +// built it. With one Host there is nobody to keep apart. +func TestCoderRunOwnerRuleOnALocalBoxServingTwoHosts(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + if err := (&Config{HostName: tMember, ListenAddr: "127.0.0.1:7878"}).save(); err != nil { + t.Fatal(err) + } + if why := coderOwnerRefusal(tMember, tAnchor); why != "" { + t.Fatalf("a local machine with one host applies no owner rule: %s", why) + } + if err := (&Config{HostName: tAnchor, ListenAddr: "127.0.0.1:7879"}).save(); err != nil { + t.Fatal(err) + } + if why := coderOwnerRefusal(tMember, tAnchor); why == "" || !strings.Contains(why, "started by "+tAnchor) { + t.Fatalf("on a local machine serving two hosts one must not stop the other's run: %q", why) + } + if why := coderOwnerRefusal(tMember, ""); why == "" { + t.Fatal("on a local machine serving two hosts a run of unknown owner proves nothing about self") + } + if why := coderOwnerRefusal(tMember, tMember); why != "" { + t.Fatalf("a host stops its own run: %s", why) + } +} diff --git a/client-go/testdata/install_ps1_pair.ps1 b/client-go/testdata/install_ps1_pair.ps1 new file mode 100644 index 0000000..d795a4b --- /dev/null +++ b/client-go/testdata/install_ps1_pair.ps1 @@ -0,0 +1,58 @@ +# Runs install.ps1's -Pair block against a stand-in bastion, with the Windows +# cmdlets it calls stood in by functions that log their call. pwsh on Linux: +# pwsh -NoProfile -File testdata/install_ps1_pair.ps1 ../deploy/install.ps1 +# Exits 0 when every case holds, and prints the first that does not. +param([string]$Script) +$ErrorActionPreference = 'Stop' +$src = Get-Content -Raw $Script +$m = [regex]::Match($src, '(?ms)^if \(\$Pair\) \{\r?\n.*?^\}\r?\n') +if (-not $m.Success) { Write-Output 'FAIL: install.ps1 has no top-level if ($Pair) { … } block'; exit 1 } +$block = [scriptblock]::Create($m.Value) + +$dir = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Path $dir | Out-Null +$log = Join-Path $dir 'log' +$exe = Join-Path $dir 'privatae-bastion' +Set-Content -Path $exe -Value ("#!/bin/sh`necho ""ARGV `$*"" >> '$log'`n" + + "if [ ""`$1"" = pair ]; then sed 's/^/STDIN /' >> '$log'; exit `$FAKE_RC; fi`necho status-ok`n") +chmod 755 $exe + +function Stop-ScheduledTask { $script:calls += 'stop-task' } +function Start-ScheduledTask { $script:calls += 'start-task' } +function Restart-Service { $script:calls += 'restart-service' } +function Start-Process { $script:calls += 'start-process' } +function Stop-Process { $script:calls += 'stop-process' } +function Get-Process { @() } +function Start-Sleep { } +function Write-Host { param($Object) $script:out += "$Object`n" } + +function Run-Case([string]$rc, [bool]$networked) { + $script:calls = @(); $script:out = '' + if (Test-Path $log) { Remove-Item $log } + $env:FAKE_RC = $rc + $Pair = 'skp_INVENTEDCODE'; $Networked = $networked + $threw = $null + try { . $block } catch { $threw = $_.Exception.Message } + $l = if (Test-Path $log) { Get-Content -Raw $log } else { '' } + return @{ log = $l; calls = ($script:calls -join ','); out = $script:out; threw = $threw } +} +function Fail([string]$why) { Write-Output "FAIL: $why"; exit 1 } + +$r = Run-Case '0' $false +if ($r.threw) { Fail "rc 0 threw: $($r.threw)" } +if ($r.log -notmatch '(?m)^ARGV pair -$' -or $r.log -match 'ARGV .*skp_') { Fail "the code must reach 'pair -' on stdin, never argv: $($r.log)" } +if ($r.log -notmatch '(?m)^STDIN skp_INVENTEDCODE\r?$') { Fail "pair's stdin: $($r.log)" } +if ($r.calls -ne 'stop-task,start-task') { Fail "per-user: stop, then start the logon task: $($r.calls)" } +if ($r.out -notmatch 'This computer is paired\. The bastion is running, and starts again each time you sign in to Windows\.') { Fail "the ending: $($r.out)" } + +$r = Run-Case '3' $false +if ($r.threw -or $r.out -notmatch 'already paired with this Host') { Fail "exit 3 is success: $($r.threw) $($r.out)" } + +$r = Run-Case '1' $false +if (-not $r.threw -or $r.calls -ne '') { Fail "a failed pair must throw before starting anything: $($r.threw) $($r.calls)" } + +$r = Run-Case '0' $true +if ($r.threw -or $r.calls -ne 'restart-service' -or $r.out -notmatch 'Windows service PrivataeBastion') { Fail "networked restarts the service: $($r.threw) $($r.calls)" } + +Remove-Item -Recurse -Force $dir +Write-Output 'ok: install.ps1 -Pair pairs over stdin, treats exit 3 as success, stops on a failure, restarts task or service' diff --git a/client-go/uisetup.go b/client-go/uisetup.go index 91f0de5..4e07eaa 100644 --- a/client-go/uisetup.go +++ b/client-go/uisetup.go @@ -888,7 +888,7 @@ func (s *uiSetup) anchorChoose(w http.ResponseWriter, r *http.Request) { Class: "bad", Command: setModeCommandHint(), How: adminHowTo() + ", then claim the anchor.", - Why: "This machine is in local mode, where it serves one host and needs no anchor. " + + Why: "This machine is in local mode, where it serves only the Hosts you said yes to here and needs no anchor. " + "An anchor only means something on a networked bastion.", }) return diff --git a/deploy/VERSION b/deploy/VERSION index 2f4c74e..46448c7 100644 --- a/deploy/VERSION +++ b/deploy/VERSION @@ -1 +1 @@ -0.56.0 +0.57.0 diff --git a/deploy/install.ps1 b/deploy/install.ps1 index 3551fc5..09046a8 100644 --- a/deploy/install.ps1 +++ b/deploy/install.ps1 @@ -21,7 +21,17 @@ # 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) +# +# -Pair '': pair as well, with the code from your Host's "Set up a +# bastion" card, and start the bastion so it serves now and at every sign-in. +# The one line the card gives is +# & ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…' +# The code reaches the bastion on standard input, never as an argument, so it +# is not in the process list. On a machine whose bastion is already the +# Windows service (installed with -Networked, or by `npx cseries`), -Pair pairs +# that service from an Administrator PowerShell instead of installing a second +# bastion beside it. +param([switch]$Networked, [string]$Pair) # # 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 @@ -35,6 +45,18 @@ $base = 'https://dl.privatae.ai' $arch = if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -eq 'Arm64') {'arm64'} else {'amd64'} $key = "windows-$arch.exe" +# Checked before anything is downloaded. Never echoed: it holds the token. +if ($Pair -and -not $Pair.StartsWith('skp_')) { + throw "-Pair takes the pairing code from your Host's card, which starts skp_. Nothing was installed." +} +# A machine whose bastion already runs as the Windows service is paired +# through that service. A per-user install beside it would be a second +# bastion, which the service's own guard refuses to pair anyway. +if ($Pair -and -not $Networked -and (Get-Service -Name 'PrivataeBastion' -ErrorAction SilentlyContinue)) { + $Networked = $true + Write-Host "This computer's bastion runs as the Windows service PrivataeBastion; pairing that service." +} + 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. @@ -43,7 +65,7 @@ if ($Networked) { 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.") + "Close this window, right-click your terminal, choose 'Run as administrator', and run the same line again.") } } @@ -223,11 +245,54 @@ if ($Networked) { 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." + if (-not $Pair) { Write-Host "Next, anchor this machine to the Host that should govern it:" Write-Host " privatae-bastion claim-anchor" Write-Host " (add --host if this machine already serves several Hosts)" Write-Host "Every host paired after the anchor waits, pending, until that Host approves it." -} else { + } +} elseif (-not $Pair) { Write-Host "Open a NEW terminal, then: privatae-bastion pair " } + +if ($Pair) { + Write-Host "" + # The full path, never the name on PATH, which this window may not have + # picked up yet. The code goes on standard input: `pair -` reads its first + # line. `pair` exits 3 when this computer is already paired with that Host, + # which is success here too. On a computer that already serves another + # Host, `pair` asks in this window whether to add this one. + $Pair | & $exe pair - + $rc = $LASTEXITCODE + if ($rc -eq 0) { $done = 'This computer is paired.' } + elseif ($rc -eq 3) { $done = 'This computer was already paired with this Host; nothing changed there.' } + else { throw "Pairing did not finish (the reason is above). The bastion is installed; nothing else changed." } + + # A running bastion reads a new pairing at its next start, so it is + # restarted, not just started. + if ($Networked) { + Restart-Service -Name 'PrivataeBastion' + $how = 'The bastion runs as the Windows service PrivataeBastion, and starts with Windows.' + } else { + # Every copy is stopped first, from wherever it was started (a terminal, + # an older install, a detached copy after a self-update): each would + # hold this machine's first control port, 127.0.0.1:7878, and the new + # one would fail to start beside it. + Stop-ScheduledTask -TaskName 'PrivataeBastion' -ErrorAction SilentlyContinue + Get-Process -Name 'privatae-bastion' -ErrorAction SilentlyContinue | ForEach-Object { + Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue + } + try { + Start-ScheduledTask -TaskName 'PrivataeBastion' + $how = 'The bastion is running, and starts again each time you sign in to Windows.' + } catch { + Start-Process -FilePath $exe -ArgumentList 'run' -WindowStyle Hidden + $how = "The bastion is running until you sign out (its sign-in task could not be started: $($_.Exception.Message))." + } + } + Start-Sleep -Seconds 3 + & $exe status + Write-Host "" + Write-Host "$done $how" +} Write-Host "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt" diff --git a/deploy/install.sh b/deploy/install.sh index 2b13aba..7d6ed1f 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -6,7 +6,33 @@ # sudo -H privatae-bastion pair # Run as your own user (a desktop), it installs the LOCAL bastion for that user. # +# PRIVATAE_PAIR_CODE='' pairs as well, with the code from your Host's "Set +# up a bastion" card, and starts the bastion so it serves now and after a +# restart. The one line the card gives is +# curl -fsSL https://privatae.ai/bastion/install.sh | PRIVATAE_PAIR_CODE='skp_…' sh +# set -e +# The pairing code comes from the environment, never from an argument: an +# argument sits in this shell's command line for the whole install, where any +# user of this machine can read it with ps, and one at the same address could +# redeem it first. A process's environment is readable only by its owner and +# root. It is read here, before this script starts any program, and unset at +# once, so no program it starts inherits it. Never echoed: it holds the token. +pair_code=${PRIVATAE_PAIR_CODE-} +pair_given=${PRIVATAE_PAIR_CODE+1} +unset PRIVATAE_PAIR_CODE +if [ -n "$pair_given" ]; then + # skp_ and base64url: anything else is a line that lost part of the code. + case "$pair_code" in + skp_*[!A-Za-z0-9_-]* | skp_) pair_code="" ;; + skp_?*) ;; + *) pair_code="" ;; + esac + if [ -z "$pair_code" ]; then + echo "ERROR: PRIVATAE_PAIR_CODE is not a pairing code. Copy the whole line from your Host's card again (the code starts skp_). Nothing was installed." >&2 + exit 2 + fi +fi # 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 @@ -33,7 +59,9 @@ for a in "$@"; do case "$a" in --networked) networked=1 ;; --local) local=1 ;; - *) echo "unknown option: $a" >&2; exit 2 ;; + # An unknown argument is never echoed: it may be a pairing code given in + # the wrong place, and a log of the failed install must not hold it. + *) echo "ERROR: unknown option. This installer takes --networked or --local; a pairing code goes in PRIVATAE_PAIR_CODE, as your Host's card shows. Nothing was installed." >&2; exit 2 ;; esac done if [ "$networked" = 1 ] && [ "$local" = 1 ]; then @@ -54,6 +82,179 @@ paired_local() { return 1 } +# pair_as ROOT OS BIN ARGS...: run the bastion as the state it serves. As +# root that is the service's HOME (the unit's /root, launchd's /var/root), +# whatever HOME `sudo` left behind; as yourself it is your own. +pair_as() { + pa_root=$1; pa_os=$2; shift 2 + if [ "$pa_root" = 1 ]; then + if [ "$pa_os" = darwin ]; then HOME=/var/root "$@"; else HOME=/root "$@"; fi + else + "$@" + fi +} + +# bastion_copy PID: 0 when PID is a process of this user running a program +# named privatae-bastion, from any path, including one replaced on disk since +# it started. A process that has exited, or another user's, is not. +bastion_copy() { + [ -O "/proc/$1" ] || return 1 + bc_exe=$(readlink "/proc/$1/exe" 2>/dev/null) || return 1 + case "${bc_exe##*/}" in + "privatae-bastion" | "privatae-bastion (deleted)") return 0 ;; + esac + return 1 +} + +# stop_user_copies: stop every copy of this user's bastion (Linux), as +# install.ps1 does on Windows: one left running in a terminal, from an older +# install or another path. `run` holds the machine's run lock, so the copy the +# service starts would only wait behind it, and that copy never reads the new +# pairing; an older build would fail on 127.0.0.1:7878 and restart for ever. +# Asked to stop, then killed after 10 seconds. Says on stderr how many. +stop_user_copies() { + suc_pids="" + for suc_d in /proc/[0-9]*; do + suc_pid=${suc_d#/proc/} + bastion_copy "$suc_pid" || continue + kill "$suc_pid" 2>/dev/null && suc_pids="$suc_pids $suc_pid" + done + [ -n "$suc_pids" ] || return 0 + set -- $suc_pids + echo "Stopped $# copy(ies) of the bastion running outside its service (a terminal, or another install)." >&2 + for suc_i in 1 2 3 4 5 6 7 8 9 10; do + suc_left="" + for suc_pid in $suc_pids; do bastion_copy "$suc_pid" && suc_left="$suc_left $suc_pid"; done + [ -n "$suc_left" ] || return 0 + sleep 1 + done + for suc_pid in $suc_left; do kill -9 "$suc_pid" 2>/dev/null || true; done +} + +# user_unit_stays_up: 0 when the user unit privatae-bastion is running and +# has kept the same process across a few seconds, longer than its +# RestartSec, so a copy that fails at start and is restarted is caught. +# Otherwise says what systemd shows, on stderr. +user_unit_stays_up() { + sleep 1 + uu_first=$(systemctl --user show -p MainPID --value privatae-bastion 2>/dev/null) || uu_first="" + sleep 4 + uu_pid=$(systemctl --user show -p MainPID --value privatae-bastion 2>/dev/null) || uu_pid="" + uu_state=$(systemctl --user show -p ActiveState --value privatae-bastion 2>/dev/null) || uu_state="" + uu_sub=$(systemctl --user show -p SubState --value privatae-bastion 2>/dev/null) || uu_sub="" + if [ "$uu_state" = active ] && [ "$uu_sub" = running ] && [ -n "$uu_pid" ] && [ "$uu_pid" != 0 ] && [ "$uu_pid" = "$uu_first" ]; then + return 0 + fi + echo "The bastion's user service privatae-bastion is not staying up (systemd shows it ${uu_state:-unknown}/${uu_sub:-unknown}, and it has restarted)." >&2 + echo "Its log says why: journalctl --user -u privatae-bastion" >&2 + return 1 +} + +# start_bastion BIN ROOT OS: start, or restart, the bastion so it serves every +# pairing now and starts again by itself. A running bastion reads a new +# pairing at its next start, so a restart is what a fresh pairing needs. +# Prints one plain sentence saying how it will run. +start_bastion() { + sb_bin=$1; sb_root=$2; sb_os=$3 + if [ "$sb_root" = 1 ] && [ "$sb_os" = linux ] && command -v systemctl >/dev/null 2>&1; then + systemctl restart privatae-bastion || return 1 + echo "The bastion runs as the system service privatae-bastion, and starts at boot." + return 0 + fi + if [ "$sb_root" = 1 ] && [ "$sb_os" = darwin ] && [ -f /Library/LaunchDaemons/ai.privatae.bastion.plist ]; then + /bin/launchctl kickstart -k system/ai.privatae.bastion || return 1 + echo "The bastion runs as the system service ai.privatae.bastion, and starts at boot." + return 0 + fi + if [ "$sb_root" != 1 ] && [ "$sb_os" = linux ] && command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then + # A systemd user unit: yours, no root. It starts when you sign in; with + # lingering on, at boot and after you sign out as well. + sb_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" + mkdir -p "$sb_dir" + cat > "$sb_dir/privatae-bastion.service" </dev/null 2>&1 || return 1 + # Its own copy first, then every other: what is left running after this + # is only what the unit starts. + systemctl --user stop privatae-bastion >/dev/null 2>&1 || true + stop_user_copies + systemctl --user restart privatae-bastion || return 1 + user_unit_stays_up || return 1 + if loginctl enable-linger "$(id -un)" >/dev/null 2>&1; then + echo "The bastion runs as your systemd user service privatae-bastion, and starts at boot." + else + echo "The bastion runs as your systemd user service privatae-bastion while you are signed in." + echo "To keep it running at boot and after you sign out: sudo loginctl enable-linger $(id -un)" + fi + return 0 + fi + if [ "$sb_root" != 1 ] && [ "$sb_os" = darwin ]; then + # A LaunchAgent: yours, no root, started each time you log in. + sb_plist="$HOME/Library/LaunchAgents/ai.privatae.bastion.user.plist" + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$sb_plist" < + + + + Labelai.privatae.bastion.user + ProgramArguments$sb_binrun + RunAtLoad + KeepAlive + + +PLIST + /bin/launchctl bootout "gui/$(id -u)/ai.privatae.bastion.user" >/dev/null 2>&1 || true + /bin/launchctl bootstrap "gui/$(id -u)" "$sb_plist" || return 1 + echo "The bastion runs as your LaunchAgent ai.privatae.bastion.user, and starts each time you log in." + return 0 + fi + # No service manager this script can use: run it now, detached, once every + # other copy of it has stopped (stop_user_copies says why). + if [ "$sb_os" = linux ] && [ "$sb_root" != 1 ]; then stop_user_copies; fi + nohup "$sb_bin" run >/dev/null 2>&1 & + sb_pid=$! + sleep 3 + if [ "$sb_os" = linux ] && ! bastion_copy "$sb_pid"; then + echo "The bastion stopped as soon as it started. Run it in a terminal to see why: $sb_bin run" >&2 + return 1 + fi + echo "The bastion is running, until this machine restarts. Start it again with: $sb_bin run" +} + +# pair_and_start BIN CODE ROOT OS: pair with CODE, handed to `pair -` on +# standard input and never as an argument, where the process list would show +# it. Then start the bastion and show its status. `pair` exits 3 when this +# machine is already paired with that Host, which is success here too. +pair_and_start() { + ps_bin=$1; ps_code=$2; ps_root=$3; ps_os=$4 + ps_rc=0 + printf '%s\n' "$ps_code" | pair_as "$ps_root" "$ps_os" "$ps_bin" pair - || ps_rc=$? + case "$ps_rc" in + 0) ps_done="This machine is paired." ;; + 3) ps_done="This machine was already paired with this Host; nothing changed there." ;; + *) echo "ERROR: pairing did not finish (the reason is above). The bastion is installed; nothing else changed." >&2 + return 1 ;; + esac + ps_how=$(start_bastion "$ps_bin" "$ps_root" "$ps_os") || { + echo "ERROR: $ps_done But the bastion did not start. Start it with: $ps_bin run" >&2 + return 1 + } + sleep 2 + pair_as "$ps_root" "$ps_os" "$ps_bin" status || true + echo "" + echo "$ps_done $ps_how" +} + if [ "$root" = 1 ] && [ "$local" != 1 ]; then case "$(uname -s)" in Linux) if command -v systemctl >/dev/null 2>&1; then networked=1; fi ;; @@ -199,7 +400,9 @@ UNIT 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 + if [ -n "$pair_code" ]; then + : + elif [ "$networked" = 1 ]; then echo "Networked bastion. Pair it with YOUR host; the first host paired becomes its anchor:" echo " sudo -H privatae-bastion pair " echo "Then enrol your second factor, so you can approve here:" @@ -224,6 +427,7 @@ elif [ "$darwin_service" = 1 ]; then HOME=/var/root "$bin" set-mode networked || die "could not set networked mode" "$bin" service install || die "could not register the system service" echo "" + if [ -z "$pair_code" ]; then echo "Networked bastion, running as a system service (launchd, as root)." echo "Pair it with YOUR host; the first host paired becomes its anchor:" # The full path, not the name on PATH: /usr/local/bin may belong to the @@ -234,6 +438,7 @@ elif [ "$darwin_service" = 1 ]; then echo " sudo -H '$bin' stepup-enroll" echo "Every terminal and write waits for your approval, and every later host for your host's." echo "(Already paired before this install? Anchor with: sudo -H '$bin' claim-anchor)" + fi elif [ "$networked" = 1 ]; then die "--networked needs a system service manager: systemd on Linux, launchd on macOS" else @@ -243,7 +448,13 @@ else echo "This machine is already paired as a LOCAL bastion, and reinstalling keeps it local." echo "To make it a networked server instead, run the installer again with --networked." fi + if [ -z "$pair_code" ]; then echo "Now pair: privatae-bastion pair " echo "Run it: privatae-bastion run (Linux non-root: 'loginctl enable-linger \$USER' + a --user unit for boot persistence)" + fi +fi +if [ -n "$pair_code" ]; then + echo "" + pair_and_start "$bin" "$pair_code" "$root" "$os" || exit 1 fi echo "privatae-bastion is free software under the GNU GPL v3 or later: https://privatae.ai/bastion/LICENSE.txt" diff --git a/docs/claims.md b/docs/claims.md index 5955d90..9571468 100644 --- a/docs/claims.md +++ b/docs/claims.md @@ -174,6 +174,24 @@ says so. Those parts are enforced only by the code as written. | A networked machine is anchored to its first host | `pair_anchor_test.go`: `TestPairAnchorsANetworkedMachineToItsFirstHost` | On a networked machine with no anchor, the first pairing made with machine authority anchors it and is active; a local machine, an anchored one (a new host stays pending), a second pairing, and a caller without machine authority are left as they were. `claim-anchor` afterwards succeeds and changes nothing. Called directly, not through `pair`'s redeem. | | | `anchor_test.go`: `TestClaimAnchorWithNoToken` | `claim-anchor` with no token on a machine anchored to that host succeeds and changes nothing, the host named in full, by handle or not at all; when the anchor's pairing is pending or revoked it is refused and the state is left as it was; another host is refused without `--rotate`. | | A root reinstall keeps a paired local machine local | `install_sh_test.go`: `TestInstallShKeepsAPairedLocalMachineLocal` | `install.sh`'s check finds a pairing in a folder whose mode is local (or unset), in either folder it looks in; a networked folder, or one with no pairing, gets the networked default. The rest of the installer is not run by a test. | +| A pairing code pins the Host's CA by its fingerprint | `pair_code_linux_test.go`: `TestAPairingCodePinsTheHostCAByFingerprint` | A pairing code whose enroll port presents no CA, another CA, a CA under another fingerprint, a leaf another CA signed, or a leaf without the name `host.cseries.internal` is refused before the token is spent, and nothing is saved. | +| | `pair_code_linux_test.go`: `TestAPairingCodePairsAsTheHostCAFileDoes` | A good code makes the same TCP pairing `--redeem-url` and `--host-ca` make, with that CA pinned; the same code again is "already paired" (exit 3) and changes nothing; a Host of the same name under another CA is a plain refusal. | +| | `pair_code_linux_test.go`: `TestAPairingCodeIsReadStrictly` | A code that is garbled, another version, or has an extra, missing or malformed field is refused, and no refusal repeats the token. | +| | `pair_code_linux_test.go`: `TestACodeTheHostMadeDecodesAndPins` | A code made by the C-Series Host's own `pairing_code` decodes to its address and token, and its fingerprint is the one `caSHA256` computes from the same CA. | +| | `pair_code_linux_test.go`: `TestAPairingCodeWithAKeyGivenTwiceIsRefused` | A code that names a field twice is refused. | +| | `pair_code_linux_test.go`: `TestThePinnedChainTakesOnlyAValidCAWithTheFingerprint` | A certificate with the fingerprint that is not a CA, an expired CA, or a chain of the leaf alone is refused; the pinned CA after other certificates is the one kept. Called directly, not through TLS. | +| | `pair_code_linux_test.go`: `TestHostCAFileWithASameNamedHostUnderAnotherCAIsNotAlreadyPaired` | With `--host-ca` too, a Host of the same name under another CA is a plain refusal, not exit 3, and its token is not spent; the same Host under its own CA is exit 3. | +| | `pair_code_linux_test.go`: `TestARedeemAnsweringAPairedNameUnderAnotherCAIsNotAlreadyPaired` | When the redeem answers with a paired name the token's handle did not show, a Host of that name under another CA is a plain refusal, not exit 3, and nothing is saved. | +| A local machine adds a second host only on a yes typed there | `pair_code_linux_test.go`: `TestALocalMachineAddsASecondHostOnlyOnAYesTypedHere` | With no terminal, or any answer but y or yes, the second host is refused before its token is spent and the first pairing is unchanged; callers with no person to ask keep the refusal and open no terminal; a typed yes adds it beside the first. | +| | `pair_code_linux_test.go`: `TestTheSecondHostAnswerIsNeverReadFromStandardInput` | The answer is read from the controlling terminal, never from standard input: with no terminal and a y waiting on standard input, the question finds no terminal, refuses, and leaves standard input unread. | +| | `service_test.go`: `TestServiceOwnerRuleOnAnAnchoredBox` | On a local machine that serves two hosts, a host may not replace or stop a server the other built, and may change its own; with one host no owner rule applies. | +| | `service_test.go`: `TestCoderRunOwnerRuleOnALocalBoxServingTwoHosts` | On a local machine that serves two hosts, a host may not stop a coder run the other started, or one of unknown owner, and may stop its own; with one host nothing changes. | +| | `pair_code_linux_test.go`: `TestTheSecondHostQuestionEscapesTheTokensName` | The second-host question shows a token's name that is not a plain host name quoted, with its control bytes escaped. | +| The installers keep the pairing code out of every process's arguments | `install_sh_test.go`: `TestInstallShTakesTheCodeOnlyFromTheEnvironment` | `install.sh` takes the code only from `PRIVATAE_PAIR_CODE` and unsets it before it starts any program: no program it starts, the downloaded bastion included, has the code in its arguments, its shell's command line or its environment, and the code reaches `pair -` on standard input. Run on stand-in programs; nothing is downloaded. | +| | `install_sh_test.go`: `TestInstallShRefusesABadPairCodeBeforeDownloading` | An empty or partial `PRIVATAE_PAIR_CODE`, and any unknown argument (the removed `--pair` and a bare code included), are refused before the download and never echoed. | +| | `install_sh_test.go`: `TestInstallShPairsOverStdinAndStartsAUserUnit` | The pairing functions hand the code to `pair -` on standard input, then start the bastion; a failed pair starts nothing. Run on stand-in programs, not on a real systemd. | +| | `install_sh_test.go`: `TestInstallShStopsAnOutsideCopyAndChecksTheUnitStaysUp` | On Linux as yourself, a copy of the bastion running outside the user unit is stopped before the unit starts, and a unit that keeps restarting is reported as not running, with a failure. Run on a stand-in systemd, in a PID namespace of its own. | +| | `install_ps1_test.go`: `TestInstallPs1PairShape` | `install.ps1` pipes the code to `pair -`, never into an argument or the output, and checks it before downloading. Read from the file; `TestInstallPs1PairBlockRunsUnderPwsh` runs the block where pwsh is installed. Not run on Windows. | | A terminal is checked here and logged | `session_test.go`: `TestSessionOpensWithoutAGrantOnALocalBox` | On a local machine a terminal with no grant and no approval opens and is audited. The same test calls `sessionPinOfferedKey` directly: the first key offered is pinned and a different one is refused. | | | `session_test.go`: `TestSessionRefusedWithoutGrantIs126` | A grant that is sent is verified: a bad signature, an op not listed, or an op the grant denies is refused and audited. | | | `fuzz_grants_test.go`: `FuzzEachOpIsAllowedOnlyByItsOwnFamily` | On an anchored machine the family check (`capsAllow`) lets a host other than the anchor run an op, the session ops included, only if its pairing grant names that op's family. Called directly, not through the terminal handler. | diff --git a/docs/commands.md b/docs/commands.md index 0656128..a2a28e0 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -7,6 +7,9 @@ | `pair --replace ` | Re-pair an existing host: rotates its credentials, keeps its port, context store and DCN state. | | `pair [--replace] --redeem-url unix:/// ` | Pair with a Host on this machine over its enroll socket. Its CA is pinned for life. See [A Host on this machine](host-on-this-machine.md). | | `pair [--replace] --redeem-url https://:/ --host-ca ` | Pair with a Host in a VM on this machine over its enroll port, trusting that CA alone. Its CA is pinned for life. See [A Host in a VM on this machine](host-on-this-machine.md#a-host-in-a-vm-on-this-machine). | +| `pair ` | Pair with the one code (`skp_…`) a Cloud Host's "Set up a bastion" card gives: it carries the enroll address and the fingerprint of the Host's CA, and the CA is taken from the enroll port only under that fingerprint. On a local machine that already serves a Host it asks, on this machine's terminal, whether to add this one; only a yes adds it. | +| | The card's one line installs the bastion, pairs it with that code and starts it: `curl -fsSL https://privatae.ai/bastion/install.sh \| PRIVATAE_PAIR_CODE='skp_…' sh` on macOS and Linux (the code goes in the environment, never in an argument, which the process list would show), and `& ([scriptblock]::Create((irm https://privatae.ai/bastion/install.ps1))) -Pair 'skp_…'` on Windows. Either hands the code to `pair -` on standard input. | +| | `pair` exits 0 when it paired, 3 when this machine is already paired with that Host (nothing changed), and anything else on a failure. | | `run` | Bring up the tunnel and lanes for every paired host. | | `status` | Show every pairing and its tunnel status. | | `ui` | Open the local console in your browser. |