// 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" "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")) token := firstNonEmpty(os.Getenv("BAO_TOKEN"), os.Getenv("VAULT_TOKEN")) if addr == "" || token == "" { return nil, fmt.Errorf("OpenBao is not configured: set BAO_ADDR and BAO_TOKEN " + "(see docs/seeding-runbook.md)") } mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret") return &Store{ addr: strings.TrimSuffix(addr, "/"), token: token, mount: mount, prefix: "fluid-telegram/" + campaign + "/telegram", 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) } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" }