bastion 0.57.0
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m30s
ci / fuzz (push) Successful in 3m25s

This commit is contained in:
Privatae
2026-10-28 20:35:45 +00:00
parent 4012da0b75
commit 081763a675
26 changed files with 2118 additions and 37 deletions
+151 -16
View File
@@ -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 <token>`. "+
"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 <token>`. "+
"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 <token>` 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 <the Host's CA, PEM>: 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 <token>` 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 <token>` 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 <token>` to rotate its credentials", prev.HostName)
return nil, nil, alreadyPairedRefusal(prev.HostName)
}
}
opSecret, err := genSecret(24)