ops-warden routes API-key needs to railiance-platform, whose convention is platform/workloads/<domain>/<workload>/<bundle>. This repository invented secret/fluid-telegram/<campaign>/telegram instead, which is not its call to make -- a service that picks its own paths in someone else's store is how a policy ends up written around a mistake. Mount and prefix are now BAO_MOUNT and FLUID_BAO_PREFIX, with the old scheme kept as a development fallback. The runbook points at `warden access` for the current shape and at OIDC login rather than a plain token, and names the check that tells whether a login actually took. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa Assistant: claude-code Assistant-Model: opus Assistant-Process: 1361245@bnt-lap001 Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
178 lines
5.8 KiB
Go
178 lines
5.8 KiB
Go
// 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)
|
|
}
|
|
// Mount and prefix are configurable because this repo does not get to invent
|
|
// the fleet's secret layout. ops-warden routes API-key needs to
|
|
// railiance-platform, whose convention is
|
|
// platform/workloads/<domain>/<workload>/<bundle> -- see
|
|
// ops-warden/wiki/CredentialRouting.md. The defaults below are a local
|
|
// fallback for development, not the intended production location.
|
|
mount := firstNonEmpty(os.Getenv("BAO_MOUNT"), "secret")
|
|
prefix := firstNonEmpty(os.Getenv("FLUID_BAO_PREFIX"), "fluid-telegram/"+campaign+"/telegram")
|
|
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 ""
|
|
}
|