146 lines
4.4 KiB
Go
146 lines
4.4 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"
|
||
|
|
"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 ""
|
||
|
|
}
|