package main // backupreseal.go — a host asks this machine to reseal one backup's key to // it, and the person decides here. // // The host restoring a backup needs that backup's own key and nothing more. // It sends the sealed sidecar and its own public key (backup_reseal start). // This machine files the request and answers only "pending": nothing is // opened yet. The person sees the request here (`privatae-bastion backup // requests`), in this machine's words — which backup, which host, which key // it would be sealed to — and approves it (`backup approve `). Approving // runs on this machine as the person, with the backup key held here: the // sidecar is opened, checked to be the backup it claims, and the same record // is sealed to the host's key alone. The host collects it (backup_reseal // status). Refusing leaves the host nothing. // // Why a request queue of its own and not the generic per-run approval // (writes.go): that approval gates CHANGES to this machine on an anchored box // and passes everything on a local one. A reseal changes nothing here, but it // hands a host the key to one backup, and that is the person's decision on // every box. And the act needs this bastion's backup key, which only the // person's own session here can read. So the approval and the act are the same step, taken // here. When this pairing has a second factor enrolled, approving also asks // for its code, as `approve` does. // // WHOSE BACKUP. A request is bound to the pairing it came in on, and to the // host number that pairing was given at pairing: the host's tunnel address, // fd00:9::e:, which the platform derives from the host's own entity // and which this machine stored at `pair`. // Nothing the host sends can change it. Every host that shares this // bastion's key holds sidecars of every other such host's backups (a shared // folder, the platform), so a label or a stamp is no proof of whose backup a // sidecar is: the host number inside it is, once opened here. A sidecar of // another host's backup is REFUSED, not shown for the person to catch: host A, // taken over and calling itself B, never gets B's backup key sealed to A. // Restoring a lost host's backups onto the one that replaces it (a different // number) is the person's own act at this machine, `backup restore-plan` // (backupcli.go), not a request a host can file. import ( "bufio" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/netip" "os" "path/filepath" "sort" "strings" "sync" "time" "unicode" "unicode/utf8" "filippo.io/age" "github.com/gorilla/websocket" ) const ( resealRequestTTL = 24 * time.Hour // A host files requests into this machine's state; these bound how much // it can file. A restore needs one request per backup it restores, and // the person answers them. They bound the requests WAITING for an // answer: answering one (approve or deny) frees its room at once, as a // request that lapses (a day) does. resealMaxRequests = 16 resealMaxBytes = 512 << 10 resealLabelMax = 120 // An answered request is kept, without the sidecar it came with, so the // host can read the outcome (and a resealed key) until it lapses. Only // the person's answers make them, and at most this many are kept, the // oldest answered going first. resealMaxAnswered = 64 ) type resealRequest struct { ID string `json:"id"` Stamp string `json:"stamp"` // Pairing and HostNumber are this machine's record of who sent it, taken // from the pairing the request came in on; HostLabel is the host's words. Pairing string `json:"pairing"` HostNumber int64 `json:"host_number"` HostLabel string `json:"host_label"` To string `json:"to"` // age1 recipient of the restoring host ToFP string `json:"to_fp"` // its fingerprint, shown to the person Sidecar string `json:"sidecar"` // armored, as the host sent it SidecarSH string `json:"sidecar_sha256"` At string `json:"at"` State string `json:"state"` // pending | done | refused | failed Resealed string `json:"resealed,omitempty"` Error string `json:"error,omitempty"` DecidedAt string `json:"decided_at,omitempty"` } type resealReq struct { Op string `json:"op"` // start | status JobID string `json:"job_id"` Sidecar string `json:"sidecar"` To string `json:"to"` Stamp string `json:"stamp"` HostLabel string `json:"host_label"` } var resealMu sync.Mutex // resealWho is a pairing as this machine knows it: its name and the host // number it was paired with. type resealWho struct { Name string Number int64 } // hostNumberOf reads the host number out of a pairing's tunnel address, // fd00:9::e:, as the platform assigns it. // false for any other address: such a pairing cannot be matched to a // backup's host number, and its requests are refused. func hostNumberOf(tunnelIP string) (int64, bool) { a, err := netip.ParseAddr(tunnelIP) if err != nil || !a.Is6() || a.Is4In6() || a.Zone() != "" { return 0, false } b := a.As16() prefix := [14]byte{0xfd, 0x00, 0x00, 0x09, 0, 0, 0, 0, 0, 0, 0, 0, 0x00, 0x0e} if [14]byte(b[:14]) != prefix { return 0, false } n := int64(b[14])<<8 | int64(b[15]) return n, n > 0 } // resealPairing is the pairing self as this machine has it on disk. An // anonymous caller, a pairing that is not here, not active, or has no host // number is refused: a request must be bound to a host. func resealPairing(self string) (resealWho, error) { if self == "" { return resealWho{}, errors.New("a restore request is taken only from a paired host") } cfgs, err := loadPairings() if err != nil { return resealWho{}, fmt.Errorf("the pairings on this machine are unreadable: %w", err) } for _, c := range cfgs { if c.HostName == self { return resealWhoOf(c) } } return resealWho{}, fmt.Errorf("%s is not paired with this machine", self) } func resealWhoOf(c *Config) (resealWho, error) { if !c.active() { return resealWho{}, fmt.Errorf("the pairing %s is %s", c.HostName, c.stateName()) } n, ok := hostNumberOf(c.HostTunnelIP) if !ok { return resealWho{}, fmt.Errorf("the pairing %s has no host number (its tunnel address is %q), so no backup "+ "can be matched to it; pair it again", c.HostName, c.HostTunnelIP) } return resealWho{Name: c.HostName, Number: n}, nil } // resealBindingProblem is a refusal when the request was not filed by the // pairing who, or the backup the sidecar holds is not that host's own. func resealBindingProblem(r *resealRequest, sc *backupSidecar, who resealWho) string { if r.Pairing != who.Name || r.HostNumber != who.Number || who.Number <= 0 { return fmt.Sprintf("refused: this request was filed by the pairing %q (host number %d), not by %s "+ "(host number %d)", r.Pairing, r.HostNumber, who.Name, who.Number) } if sc != nil && sc.EntityID != who.Number { return fmt.Sprintf("refused: this is the backup of host number %d, and the request came from %s, which is "+ "host number %d. A host is given only its own backups' keys. To restore another host's backup onto "+ "this one, run `privatae-bastion backup restore-plan` here yourself", sc.EntityID, who.Name, who.Number) } return "" } func resealPath(dir string) string { return filepath.Join(dir, "backup_reseal.json") } // withResealLock runs fn holding the pairing's request file for itself. Two // processes write that file: the daemon files requests, and `backup approve` // and `deny` decide them in the person's own process. resealMu covers the // goroutines of one process; the lock file (flock, LockFileEx on Windows) // covers both processes, so a request filed while the person decides is not // dropped, and a decision is not reverted by a stale copy. Every write is // atomic (atomicWrite: temp file, then rename), so a reader never sees half // a file. func withResealLock(dir string, fn func() error) error { resealMu.Lock() defer resealMu.Unlock() lf, err := os.OpenFile(filepath.Join(dir, "backup_reseal.lock"), os.O_CREATE|os.O_RDWR, 0o600) if err != nil { return err } defer lf.Close() // closing releases the lock if err := flockFile(lf); err != nil { return err } return fn() } // resealAsideSuffix names a request file set aside as unreadable. const resealAsideSuffix = ".unreadable-" // resealLoad reads the pairing's requests, less those that lapsed; the // caller holds withResealLock. A file that cannot be parsed is not dropped // and not overwritten: it is set aside beside it, under a name that says so, // and the error says where. The next request starts a new file. func resealLoad(dir string) (map[string]*resealRequest, error) { m := map[string]*resealRequest{} p := resealPath(dir) raw, err := os.ReadFile(p) if errors.Is(err, os.ErrNotExist) { return m, nil } if err != nil { return nil, fmt.Errorf("the record of restore requests cannot be read: %w", err) } if err := json.Unmarshal(raw, &m); err != nil { aside := p + resealAsideSuffix + time.Now().UTC().Format("20060102T150405.000000000Z") if rerr := os.Rename(p, aside); rerr != nil { return nil, fmt.Errorf("the record of restore requests %s is unreadable (%v) and could not be set aside (%v); "+ "nothing was changed", p, err, rerr) } return nil, fmt.Errorf("the record of restore requests was unreadable (%v). It is kept as %s and a new "+ "one is started: the requests in it must be asked again", err, aside) } if m == nil { m = map[string]*resealRequest{} } now := time.Now() for id, r := range m { if r == nil { delete(m, id) continue } at, err := time.Parse(time.RFC3339, r.At) if err != nil || now.Sub(at) > resealRequestTTL { delete(m, id) } } return m, nil } // resealRead is resealLoad under the lock, for a caller that only reads. func resealRead(dir string) (map[string]*resealRequest, error) { var m map[string]*resealRequest err := withResealLock(dir, func() error { var err error m, err = resealLoad(dir) return err }) return m, err } // resealSave writes the requests: an answered one no longer carries the // sidecar it came with, and past resealMaxAnswered answered ones the oldest // answered are dropped. func resealSave(dir string, m map[string]*resealRequest) error { var answered []*resealRequest for _, r := range m { if r.State != "pending" { r.Sidecar = "" answered = append(answered, r) } } if len(answered) > resealMaxAnswered { sort.Slice(answered, func(i, j int) bool { return answered[i].DecidedAt < answered[j].DecidedAt }) for _, r := range answered[:len(answered)-resealMaxAnswered] { delete(m, r.ID) } } raw, err := json.MarshalIndent(m, "", " ") if err != nil { return err } return atomicWrite(resealPath(dir), raw) } // handleBackupReseal answers backup_reseal start and status on the pairing's // own state directory, so one host never sees another's requests. func handleBackupReseal(ws *websocket.Conn, dir string, args []string) { if len(args) < 1 { backupFail(ws, "backup_reseal: need a JSON argument") return } var req resealReq if err := json.Unmarshal([]byte(args[0]), &req); err != nil { backupFail(ws, "backup_reseal: argument is not JSON: "+err.Error()) return } switch req.Op { case "status": m, err := resealRead(dir) if err != nil { backupFail(ws, "backup_reseal: "+err.Error()) return } r, ok := m[req.JobID] if !ok { backupOK(ws, map[string]string{"job_id": req.JobID, "state": "unknown"}) return } out := map[string]string{"job_id": r.ID, "state": r.State} if r.State == "done" { out["sidecar"] = r.Resealed } if r.Error != "" { out["error"] = r.Error } backupOK(ws, out) case "start", "": who, err := resealPairing(mgmtSelf(ws)) if err != nil { backupFail(ws, "backup_reseal: "+err.Error()) return } r, err := resealFile(dir, who, req) if err != nil { backupFail(ws, "backup_reseal: "+err.Error()) return } backupOK(ws, map[string]string{"job_id": r.ID, "state": r.State}) default: backupFail(ws, "backup_reseal: unknown op "+req.Op) } } // resealFile validates and files a request. The same sidecar for the same // key while one is still open returns that one: a host that lost the reply // asks again and must not fill the person's list with copies. who is the // pairing the request came in on, recorded with it. func resealFile(dir string, who resealWho, req resealReq) (*resealRequest, error) { if who.Name == "" || who.Number <= 0 { return nil, errors.New("a restore request is taken only from a paired host with a host number") } if len(req.Sidecar) == 0 || len(req.Sidecar) > sidecarMaxBytes { return nil, errors.New("sidecar is missing or too large") } if !strings.Contains(req.Sidecar, "-----BEGIN AGE ENCRYPTED FILE-----") { return nil, errors.New("sidecar must be an armored age file") } if !backupStampRe.MatchString(req.Stamp) { return nil, errors.New("stamp must name a backup (YYYYMMDDTHHMMSSZ)") } to, err := parseBackupRecipient(req.To) if err != nil { return nil, err } raw, err := recipientRaw(to) if err != nil { return nil, err } label := strings.TrimSpace(req.HostLabel) if !resealLabelOK(label) { return nil, fmt.Errorf("host_label must be one line of at most %d bytes of printable text", resealLabelMax) } sum := sha256.Sum256([]byte(req.Sidecar)) sh := hex.EncodeToString(sum[:]) var filed *resealRequest err = withResealLock(dir, func() error { m, err := resealLoad(dir) if err != nil { return err } waiting, held := 0, 0 for _, r := range m { if r.State != "pending" { continue } if r.SidecarSH == sh && r.To == to.String() && r.Pairing == who.Name && r.HostNumber == who.Number { filed = r return nil } waiting++ held += len(r.Sidecar) } if waiting >= resealMaxRequests || held+len(req.Sidecar) > resealMaxBytes { return fmt.Errorf("this bastion already holds %d restore requests from this host waiting for an answer "+ "(%d KiB; at most %d requests or %d KiB). Answer them on this machine (privatae-bastion backup "+ "requests), or wait for them to lapse after a day, then ask again", waiting, held>>10, resealMaxRequests, resealMaxBytes>>10) } idb := make([]byte, 6) if _, err := rand.Read(idb); err != nil { return err } r := &resealRequest{ ID: "br-" + hex.EncodeToString(idb), Stamp: req.Stamp, Pairing: who.Name, HostNumber: who.Number, HostLabel: label, To: to.String(), ToFP: backupKeyFingerprint(raw), Sidecar: req.Sidecar, SidecarSH: sh, At: time.Now().UTC().Format(time.RFC3339), State: "pending", } m[r.ID] = r if err := resealSave(dir, m); err != nil { return err } filed = r return nil }) if err != nil { return nil, err } return filed, nil } // resealDecide carries out the person's answer for the pairing who (whose // state directory dir is). Approve opens the sidecar with the keys held here, // refuses it unless it is the backup of that pairing's own host number, and // seals it to the requesting host's key; any failure is recorded as the // request's outcome for the host to read. func resealDecide(dir string, who resealWho, id string, approve bool) (*resealRequest, error) { return resealDecideWith(dir, who, id, approve, "") } // resealRefuse refuses a request with the reason given, for the host to read. func resealRefuse(dir string, who resealWho, id, why string) (*resealRequest, error) { return resealDecideWith(dir, who, id, false, why) } func resealDecideWith(dir string, who resealWho, id string, approve bool, why string) (*resealRequest, error) { var out *resealRequest err := withResealLock(dir, func() error { var err error out, err = resealDecideLocked(dir, who, id, approve, why) return err }) return out, err } func resealDecideLocked(dir string, who resealWho, id string, approve bool, why string) (*resealRequest, error) { m, err := resealLoad(dir) if err != nil { return nil, err } r, ok := m[id] if !ok { return nil, fmt.Errorf("no reseal request %s (requests lapse after a day)", id) } if r.State != "pending" { return nil, fmt.Errorf("request %s is already %s", id, r.State) } r.DecidedAt = time.Now().UTC().Format(time.RFC3339Nano) if !approve { r.State, r.Error = "refused", why return r, resealSave(dir, m) } if p := resealBindingProblem(r, nil, who); p != "" { r.State, r.Error = "refused", p return r, resealSave(dir, m) } ids, err := backupIdentities() if err != nil { return nil, err } to, err := parseBackupRecipient(r.To) if err != nil { return nil, err } plain, sc, err := openSidecar([]byte(r.Sidecar), ids, r.Stamp) switch { case err != nil: r.State, r.Error = "failed", err.Error() case resealBindingProblem(r, sc, who) != "": r.State, r.Error = "refused", resealBindingProblem(r, sc, who) default: out, err := sealSidecarTo(plain, to) if err != nil { r.State, r.Error = "failed", err.Error() } else { r.State, r.Resealed = "done", string(out) } } if serr := resealSave(dir, m); serr != nil { return nil, serr } return r, nil } // resealLabelOK: the label is shown to the person on this machine as the // host's own words, so it must not be able to move the cursor, recolour the // terminal, reverse text or break the line: no control or format character // of any kind, valid UTF-8, one short line. func resealLabelOK(label string) bool { if len(label) > resealLabelMax || !utf8.ValidString(label) { return false } for _, r := range label { if unicode.IsControl(r) || unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp) { return false } } return true } // resealPreview is what the person reads before approving: which pairing // sent it (this machine's record), what the host says it wants, and what the // sidecar itself says, opened here with this bastion's key. The returned // refusal is not "" when approving cannot go through: the sidecar is not the // backup of that pairing's own host (refused, whatever the person answers), // or it does not open here, or names another backup. func resealPreview(r *resealRequest, ids []age.Identity, who resealWho) (string, string) { var b strings.Builder b.WriteString(describeReseal(r)) b.WriteString("\n\n What the sidecar says (opened here with this bastion's key):\n") if p := resealBindingProblem(r, nil, who); p != "" { fmt.Fprintf(&b, " not opened: %s\n", p) return b.String(), p } _, sc, err := openSidecar([]byte(r.Sidecar), ids, "") if err != nil { fmt.Fprintf(&b, " it does not open here: %q\n Approving will fail and the host is told so.\n", err.Error()) return b.String(), "" } // Every field that came out of the sidecar is printed quoted: the sidecar // was written by a host, and what it says must not be able to move the // cursor or pass for this machine's own words. fmt.Fprintf(&b, " host number %d\n made at %q\n backup %q (%q)\n", sc.EntityID, sc.MadeAt, sc.Object, sc.Stamp) if p := resealBindingProblem(r, sc, who); p != "" { fmt.Fprintf(&b, " %s.\n", p) return b.String(), p } if sc.Stamp != r.Stamp { fmt.Fprintf(&b, " This is the backup of %q, not the %q the host asked for: approving will fail.\n", sc.Stamp, r.Stamp) } return b.String(), "" } // resealApprove is the person's approval, checked: when this pairing has a // second factor enrolled, the code must verify (and is spent) before // anything is opened. A wrong or missing code leaves the request pending. func resealApprove(dir string, who resealWho, id, code string) (*resealRequest, error) { if stepupEnrolled(dir) { code = strings.TrimSpace(code) if code == "" { return nil, errors.New("this machine has a second factor enrolled: approving needs its code") } if _, err := stepupVerifyAndBurn(dir, code, time.Now()); err != nil { return nil, err } } return resealDecide(dir, who, id, true) } func findResealRequest(m map[string]*resealRequest, prefix string) (string, error) { var hits []string for id := range m { if strings.HasPrefix(id, prefix) { hits = append(hits, id) } } switch len(hits) { case 0: return "", fmt.Errorf("no reseal request starts with %q", prefix) case 1: return hits[0], nil } return "", fmt.Errorf("%q matches %d requests; give more of the id", prefix, len(hits)) } func describeReseal(r *resealRequest) string { host := r.HostLabel if host == "" { host = "your host" } // The pairing name was checked as a host name at `pair` (validHostName) // and the number is this machine's own; the label is the host's words and // printed quoted. return fmt.Sprintf("%s [%s]\n Sent by the pairing %s, host number %d (this machine's record).\n"+ " What the host says: %q asks for the key to the backup of %s,\n"+ " sealed to its own key %s (fingerprint %s). Approving lets that host\n"+ " open this one backup and no other, and only if it is that host's own.", r.ID, r.State, r.Pairing, r.HostNumber, host, r.Stamp, r.To, r.ToFP) } // ── the person's side, on this machine ────────────────────────────────────── // backupRequestView is one restore request as a read shows it: the armored // key the host sent, and the one resealed for it, are left out (the digest of // the one the host sent stays); both are sealed, and a read has no use for // them. type backupRequestView struct { ID string `json:"id"` State string `json:"state"` Stamp string `json:"stamp"` Pairing string `json:"pairing"` HostNumber int64 `json:"host_number"` HostLabel string `json:"host_label"` To string `json:"to"` ToFP string `json:"to_fp"` SidecarSHA string `json:"sidecar_sha256"` At string `json:"at"` DecidedAt string `json:"decided_at,omitempty"` Error string `json:"error,omitempty"` Words string `json:"words"` } type backupRequestsRead struct { Host string `json:"host"` Requests []backupRequestView `json:"requests"` // SetAside are request files found unreadable and kept, not read. SetAside []string `json:"set_aside,omitempty"` } func readBackupRequests(o readOpts) (backupRequestsRead, error) { cfg, err := stepupPairing(o.Host) if err != nil { return backupRequestsRead{}, err } dir := cfg.dcnDir() v := backupRequestsRead{Host: cfg.HostName, Requests: []backupRequestView{}} // Request files set aside as unreadable (resealLoad) are named here, so // the person learns of them whichever process found them. v.SetAside, _ = filepath.Glob(resealPath(dir) + resealAsideSuffix + "*") m, err := resealRead(dir) if err != nil { return v, err } ids := make([]string, 0, len(m)) for id := range m { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { return m[ids[i]].At < m[ids[j]].At }) for _, id := range ids { r := m[id] v.Requests = append(v.Requests, backupRequestView{ID: r.ID, State: r.State, Stamp: r.Stamp, Pairing: r.Pairing, HostNumber: r.HostNumber, HostLabel: r.HostLabel, To: r.To, ToFP: r.ToFP, SidecarSHA: r.SidecarSH, At: r.At, DecidedAt: r.DecidedAt, Error: r.Error, Words: describeReseal(r)}) } return v, nil } func cmdBackupRequests(host string, asJSON bool) error { v, err := readMachine("backup-requests", readOpts{Host: host}, readBackupRequests) if err != nil { return laneRefusalError("backup requests", err) } if asJSON { return printJSON(os.Stdout, v) } printBackupRequests(os.Stdout, v) return nil } func printBackupRequests(w io.Writer, v backupRequestsRead) { if len(v.SetAside) > 0 { fmt.Fprintln(w, "set aside as unreadable (kept, not read):") for _, a := range v.SetAside { fmt.Fprintln(w, " "+a) } fmt.Fprintln(w) } if len(v.Requests) == 0 { fmt.Fprintln(w, "no backup requests from", v.Host) return } for _, r := range v.Requests { fmt.Fprintln(w, r.Words) fmt.Fprintln(w) } } func cmdBackupDecide(host, prefix string, approve bool) error { cfg, err := stepupPairing(host) if err != nil { return err } dir := cfg.dcnDir() m, err := resealRead(dir) if err != nil { return err } id, err := findResealRequest(m, prefix) if err != nil { return err } var r *resealRequest if approve { who, err := resealWhoOf(cfg) if err != nil { return err } ids, _ := backupIdentities() text, refusal := resealPreview(m[id], ids, who) fmt.Println(text) if refusal != "" { // Refused whatever the person answers, and needs no code: a // refusal hands nothing over. if _, err := resealRefuse(dir, who, id, refusal); err != nil { return err } return errors.New(refusal) } code := "" if stepupEnrolled(dir) { fmt.Print("Approve this? Enter the code from your authenticator (or leave empty to cancel): ") code, _ = bufio.NewReader(os.Stdin).ReadString('\n') if strings.TrimSpace(code) == "" { fmt.Println("cancelled — nothing approved") return nil } } r, err = resealApprove(dir, who, id, code) if err != nil { return err } } else { r, err = resealRefuse(dir, resealWho{Name: cfg.HostName}, id, "") } if err != nil { return err } switch r.State { case "done": fmt.Println("approved — the key to that one backup is sealed to your host; it collects it on its next check.") case "refused": if approve { return errors.New(r.Error) } fmt.Println("refused — your host is told on its next check.") default: return fmt.Errorf("the reseal failed: %s", r.Error) } return nil }