// Package secrets is the provisioner's only route to credentials. // // Everything sensitive lives in OpenBao: the operator's MTProto session, the // app credentials, the bot token, and the redaction salt. Nothing here writes a // secret to disk, and nothing returns one in an error message -- an error says // which path failed, never what was at it. package secrets import ( "bytes" "context" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "time" ) // Paths under the interface's subtree. The campaign is part of the path so that // two campaigns on one interface cannot read each other's credentials. const ( KeyOperatorApp = "operator-app" // api_id, api_hash KeyOperatorSession = "operator-session" // MTProto session; a full-account credential KeyBotToken = "bot-token" KeyRedactionSalt = "redaction-salt" ) type Store struct { addr string token string mount string prefix string hc *http.Client } // NewFromEnv builds a store from the ambient OpenBao configuration. func NewFromEnv(campaign string) (*Store, error) { addr := firstNonEmpty(os.Getenv("BAO_ADDR"), os.Getenv("VAULT_ADDR")) // Fall back to the token sink the bao and vault CLIs already use, so an // operator who has logged in once does not have to re-export a secret -- // and so a token never has to be typed into a shell that records history. token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN"), tokenFromDisk()) // Name the variable that is actually missing. "set both" sends someone // checking the one they already set. switch { case addr == "" && token == "": return nil, fmt.Errorf("OpenBao is not configured: set BAO_ADDR and BAO_TOKEN " + "(see docs/seeding-runbook.md)") case addr == "": return nil, fmt.Errorf("BAO_ADDR is not set (BAO_TOKEN is)") case token == "": return nil, fmt.Errorf("no OpenBao token: set BAO_TOKEN, or run `bao login` "+ "to write ~/.vault-token (BAO_ADDR is %s)", addr) } // The fleet convention, confirmed with the owner: // platform/workloads///, per // ops-warden/wiki/CredentialRouting.md and railiance-platform. // // The campaign sits between the workload and the bundle so that two // campaigns on one interface cannot read each other's credentials -- the // bundle then names the secret itself (operator-app, bot-token, ...). // Both halves stay overridable: the layout is the owner's to change, not // this repository's. mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "platform") prefix := firstNonEmpty(os.Getenv("FLUID_BAO_PREFIX"), "workloads/infotech/fluid-telegram/"+campaign) return &Store{ addr: strings.TrimSuffix(addr, "/"), token: token, mount: mount, prefix: strings.Trim(prefix, "/"), hc: &http.Client{Timeout: 20 * time.Second}, }, nil } func (s *Store) path(key string) string { return fmt.Sprintf("%s/v1/%s/data/%s/%s", s.addr, s.mount, s.prefix, key) } // Ref is the human-readable location of a secret, safe to print. Used in plans // and error messages so an operator can find what is missing. func (s *Store) Ref(key string) string { return fmt.Sprintf("bao:%s/%s/%s", s.mount, s.prefix, key) } type kvPayload struct { Data struct { Data map[string]string `json:"data"` } `json:"data"` } // Get returns the fields at a path. Missing is reported as (nil, false, nil): // absence is an ordinary state on a first run, not a failure. func (s *Store) Get(ctx context.Context, key string) (map[string]string, bool, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.path(key), nil) if err != nil { return nil, false, err } req.Header.Set("X-Vault-Token", s.token) resp, err := s.hc.Do(req) if err != nil { return nil, false, fmt.Errorf("read %s: %w", s.Ref(key), err) } defer resp.Body.Close() switch resp.StatusCode { case http.StatusNotFound: return nil, false, nil case http.StatusOK: default: return nil, false, fmt.Errorf("read %s: unexpected status %s", s.Ref(key), resp.Status) } var p kvPayload if err := json.NewDecoder(resp.Body).Decode(&p); err != nil { return nil, false, fmt.Errorf("read %s: %w", s.Ref(key), err) } return p.Data.Data, true, nil } // Put replaces the fields at a path. func (s *Store) Put(ctx context.Context, key string, fields map[string]string) error { body, err := json.Marshal(map[string]any{"data": fields}) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.path(key), bytes.NewReader(body)) if err != nil { return err } req.Header.Set("X-Vault-Token", s.token) req.Header.Set("Content-Type", "application/json") resp, err := s.hc.Do(req) if err != nil { return fmt.Errorf("write %s: %w", s.Ref(key), err) } defer resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("write %s: unexpected status %s", s.Ref(key), resp.Status) } return nil } // CreateIfAbsent writes fields only when nothing is there, and reports whether // it wrote. It has no counterpart that overwrites, and that is deliberate: the // redaction salt is stored this way, and rotating it silently invalidates every // longitudinal comparison the interface has made, with no visible failure. A // tool that can rewrite it is a tool that eventually will. func (s *Store) CreateIfAbsent(ctx context.Context, key string, fields map[string]string) (bool, error) { _, found, err := s.Get(ctx, key) if err != nil { return false, err } if found { return false, nil } return true, s.Put(ctx, key, fields) } // tokenFromDisk reads ~/.vault-token, the file `bao login` writes. Absence is // not an error: it is one of several ways a token may be supplied. func tokenFromDisk() string { home, err := os.UserHomeDir() if err != nil { return "" } raw, err := os.ReadFile(filepath.Join(home, ".vault-token")) if err != nil { return "" } return strings.TrimSpace(string(raw)) } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" }