Files
Privatae e4c9b69b46
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s
bastion 0.53.2
2026-10-20 04:42:14 +00:00

212 lines
7.9 KiB
Go

package main
// anchor_release.go — `privatae-bastion release-anchor --host <name>`: let go
// of an anchor whose Host is gone.
//
// THE CASE. A Host that runs on this machine is deleted with its data and set
// up again (`npx cseries uninstall --delete-data`, then `npx cseries`). The new
// Host has a new CA and a new signing key. The bastion, anchored to the old
// one, cannot take it:
//
// - `pair --replace` under another CA is refused (pair_tcp.go), rightly: a
// Host keeps its CA for life;
// - the anchor cannot be unpaired (config.go unpairHost);
// - a new pairing waits, pending, for the old anchor to approve it (pair.go);
// - rotating the anchor needs the old anchor's consent or 14 days of its
// silence (anchor.go rotationPermitted), and the old one is gone.
//
// So the machine's administrator says so. This command, with machine
// authority:
//
// 0. checks that the Host is gone: --host-ca names the CA the new Host
// brought, and it is refused when that is the CA this box pinned for the
// host (releaseCARefusal). The same CA is the same Host, alive. Only
// --force, taken at a terminal and never from an installer, skips this;
// 1. drops the anchor, if it is the named host (anchor.json and any rotation
// consent left for it). A box anchored to another host is refused: naming
// the host is the check that this is the anchor the administrator means;
// 2. unpairs that host;
// 3. sets its DCN state aside (dcn/<host> → dcn/<host>.released-<when>): the
// signing key pinned for the old Host, the second factor enrolled for it
// and the grants it signed. A new pairing under the same name would
// otherwise inherit that pin, and every grant the new Host signs would be
// refused as a key mismatch. Nothing is deleted.
//
// After it the box is unanchored, and a networked box with no anchor serves
// nothing (anchor.go), so the next steps belong in the same breath: `pair` the
// new Host, then `claim-anchor`.
//
// It adds no power. An administrator could already delete anchor.json by hand
// (anchor.go header); this does it with the checks above, and says what it did.
// Approval device keys pinned on the old anchor go with anchor.json and are
// pinned again with pin-device. Running it again finds nothing left and
// succeeds, so an installer can retry a step that stopped half-way.
import (
"errors"
"fmt"
"io/fs"
"os"
"strings"
"time"
)
// releaseConsole is a seam so the suite drives --force on any machine; nothing
// outside tests assigns it.
var releaseConsole = stdioIsConsole
// releaseCARefusal says why the Host being released is not shown to be gone,
// or "" when it is: the CA its replacement brought (newCA, from --host-ca)
// must differ from the one this box pinned for it. The same CA is the same
// Host, alive, and releasing it would drop a live anchor and set aside its key
// pin and factor for nothing. A pin this box does not hold cannot be compared,
// so that too is refused; --force, at a terminal, is the administrator saying
// they know.
func releaseCARefusal(name, pinned, newCA string) string {
switch {
case newCA == "":
return "name the new Host's CA with --host-ca <pem file>: release-anchor lets go only of a Host whose CA has changed (its data was deleted)"
case strings.TrimSpace(pinned) == "":
return "this box pinned no Host CA for " + name + ", so a changed CA cannot be shown"
case sameCAs(pinned, newCA):
return "the CA given with --host-ca is the one " + name + " is paired with, so that Host is not gone"
}
return ""
}
func cmdReleaseAnchor(host, hostCAFile string, force bool) error {
if err := requireMachineAuthority("release-anchor"); err != nil {
return err
}
if !validHostName(host) {
return fmt.Errorf("%q is not a host name", host)
}
// --force skips the CA check, so it is taken only from a person at a
// terminal. An installer runs its commands with their output captured and
// never passes it.
if force && !releaseConsole() {
return errors.New("release-anchor --force is taken only in an Administrator terminal; this one's input or output " +
"goes to a file or a pipe — nothing was released")
}
newCA := ""
if hostCAFile != "" {
ca, err := loadHostCA(hostCAFile)
if err != nil {
return err
}
newCA = ca
}
if err := singletonGuardForPair(); err != nil {
return err
}
cfgs, err := loadPairings()
if err != nil {
return err
}
// The exact name (or its handle), never a prefix: this removes a pairing.
cfg := pairingFor(cfgs, host)
name := host
if cfg != nil {
name = cfg.HostName
}
// Is there anything to release, and is the Host it belongs to gone? Both
// before anything changes.
a := currentAnchor()
if a != nil && a.AnchorHost != name {
return fmt.Errorf("this box is anchored to %s, not %s — release-anchor names the anchor it releases", a.AnchorHost, name)
}
if !force && (a != nil || cfg != nil) {
pinned := ""
if cfg != nil {
pinned = cfg.CACert
}
if why := releaseCARefusal(name, pinned, newCA); why != "" {
return fmt.Errorf("%s — nothing was released. If the Host really is gone, run from an Administrator terminal: "+
"privatae-bastion release-anchor --host %s --force", why, name)
}
}
// 1. The anchor, if it is this host.
released := false
if a != nil {
devices := len(deviceKeys())
anchorMu.Lock()
err := removeAnchorFiles()
anchorMu.Unlock()
if err != nil {
return fmt.Errorf("could not release the anchor: %w", err)
}
released = true
fmt.Printf("released the anchor (%s)\n", name)
if devices > 0 {
fmt.Printf(" %d approval device key(s) were pinned on it; pin them again after claim-anchor (privatae-bastion pin-device <key>)\n", devices)
}
} else if f, err := loadAnchorFile(); err == nil && f != nil && f.AnchorHost != "" && f.AnchorHost != name {
// An anchor file that does not verify is no anchor, but one naming
// another host is still not this command's to remove.
return fmt.Errorf("this box's anchor file names %s, not %s — release-anchor names the anchor it releases", f.AnchorHost, name)
}
// 2 and 3. The pairing, and its DCN state set aside.
if cfg == nil {
if !released {
fmt.Printf("nothing to release: %s is neither this box's anchor nor one of its pairings\n", name)
}
return nil
}
dcn := cfg.DCNDir
if dcn == "" {
if d, err := configDirPath(); err == nil {
dcn = d + string(os.PathSeparator) + "dcn" + string(os.PathSeparator) + cfg.HostName
}
}
if _, err := unpairHost(cfg.HostName); err != nil {
return fmt.Errorf("released the anchor, but could not unpair %s: %w", cfg.HostName, err)
}
fmt.Printf("unpaired %s\n", cfg.HostName)
if dcn != "" {
aside, err := setDCNStateAside(dcn, time.Now())
switch {
case err != nil:
return fmt.Errorf("unpaired %s, but could not set its DCN state aside (%s): %w — move it away by hand before pairing the new Host", cfg.HostName, dcn, err)
case aside != "":
fmt.Printf(" its key pin, second factor and grants are kept, set aside, in %s\n", aside)
}
}
fmt.Println(" Next: pair the new Host, then `privatae-bastion claim-anchor`. Until then this box serves nothing.")
return nil
}
// removeAnchorFiles removes anchor.json and a consent left for rotating it.
// The caller holds anchorMu.
func removeAnchorFiles() error {
p, err := anchorPath()
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
if c, err := anchorConsentPath(); err == nil {
_ = os.Remove(c)
}
return nil
}
// setDCNStateAside renames a released pairing's DCN directory out of the way of
// a new pairing under the same name. "" when there was none.
func setDCNStateAside(dir string, now time.Time) (string, error) {
if _, err := os.Lstat(dir); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return "", nil
}
return "", err
}
aside := dir + ".released-" + now.UTC().Format("20060102T150405Z")
if err := os.Rename(dir, aside); err != nil {
return "", err
}
return aside, nil
}