Implement the MTProto client, session bootstrap and apply
Completes the provisioner's write path. internal/tg drives BotFather as a conversation rather than pretending it is an endpoint, creates channels, claims usernames with fallbacks, and grants post_messages. internal/apply sequences it: bot before administrator, test channel before public, and state saved after every step that changed the world -- a channel that exists but is unrecorded is worse than one that does not exist, because the next run creates a second. The operator session lives in OpenBao, not on disk. gotd's FileStorage would leave a full-account credential in the working directory, where it outlives the run and can be committed by accident. The bot token goes straight from BotFather's reply to OpenBao and is cleared from memory; if that write fails the error says how to recover by hand and warns against re-running, since a retry creates a second bot. Closes T05: the redaction salt is create-if-absent with no overwrite path, and the test asserts it, because rotating it invalidates every longitudinal comparison with no visible failure. 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
This commit is contained in:
parent
10018a99b6
commit
5ddfee8250
14 changed files with 1544 additions and 57 deletions
145
internal/secrets/secrets.go
Normal file
145
internal/secrets/secrets.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// 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 ""
|
||||
}
|
||||
116
internal/secrets/secrets_test.go
Normal file
116
internal/secrets/secrets_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testStore(t *testing.T, h http.Handler) *Store {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("BAO_ADDR", srv.URL)
|
||||
t.Setenv("BAO_TOKEN", "test-token")
|
||||
s, err := NewFromEnv("hall-of-helix")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// The salt rule. Rotating it silently invalidates every longitudinal comparison
|
||||
// the interface has made, with no visible failure -- so the tool must have no
|
||||
// path that replaces an existing one.
|
||||
func TestCreateIfAbsentNeverOverwrites(t *testing.T) {
|
||||
var writes int
|
||||
existing := map[string]string{"salt": "the-original"}
|
||||
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
writes++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"data": existing}})
|
||||
}))
|
||||
|
||||
created, err := s.CreateIfAbsent(context.Background(), KeyRedactionSalt,
|
||||
map[string]string{"salt": "a-replacement"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
t.Error("reported creating a salt that already existed")
|
||||
}
|
||||
if writes != 0 {
|
||||
t.Errorf("wrote over an existing salt (%d writes)", writes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateIfAbsentWritesWhenMissing(t *testing.T) {
|
||||
var got map[string]any
|
||||
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
json.NewDecoder(r.Body).Decode(&got)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
|
||||
created, err := s.CreateIfAbsent(context.Background(), KeyRedactionSalt,
|
||||
map[string]string{"salt": "fresh"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created {
|
||||
t.Fatal("did not create a salt when none existed")
|
||||
}
|
||||
if got["data"].(map[string]any)["salt"] != "fresh" {
|
||||
t.Errorf("wrote %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing path is an ordinary first-run state, not an error.
|
||||
func TestGetMissingIsNotAnError(t *testing.T) {
|
||||
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
_, found, err := s.Get(context.Background(), KeyBotToken)
|
||||
if err != nil || found {
|
||||
t.Fatalf("found=%v err=%v", found, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ref is printed in plans and errors, so it must name a location and never
|
||||
// carry a value.
|
||||
func TestRefIsSafeToPrint(t *testing.T) {
|
||||
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"data": map[string]any{"data": map[string]string{"token": "123:SECRET"}}})
|
||||
}))
|
||||
ref := s.Ref(KeyBotToken)
|
||||
if strings.Contains(ref, "SECRET") || strings.Contains(ref, "test-token") {
|
||||
t.Fatalf("Ref leaked a secret: %q", ref)
|
||||
}
|
||||
if !strings.Contains(ref, "hall-of-helix") || !strings.Contains(ref, KeyBotToken) {
|
||||
t.Errorf("Ref should locate the secret: %q", ref)
|
||||
}
|
||||
}
|
||||
|
||||
// Errors name the path that failed, never what was at it.
|
||||
func TestErrorsDoNotCarryValues(t *testing.T) {
|
||||
s := testStore(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
_, _, err := s.Get(context.Background(), KeyOperatorSession)
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "test-token") {
|
||||
t.Fatalf("error leaked the bao token: %v", err)
|
||||
}
|
||||
}
|
||||
50
internal/secrets/session.go
Normal file
50
internal/secrets/session.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// SessionStorage keeps the MTProto session in OpenBao and never on disk.
|
||||
//
|
||||
// gotd's own FileStorage would put a full-account credential in the working
|
||||
// directory, where it outlives the run, gets committed by accident, and is
|
||||
// readable by anything on the box. The session can do everything the operator
|
||||
// account can do, so it is held exactly where the bot token is.
|
||||
type SessionStorage struct {
|
||||
Store *Store
|
||||
}
|
||||
|
||||
const sessionField = "session_b64"
|
||||
|
||||
func (s SessionStorage) LoadSession(ctx context.Context) ([]byte, error) {
|
||||
fields, found, err := s.Store.Get(ctx, KeyOperatorSession)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found || fields[sessionField] == "" {
|
||||
// gotd treats a nil session as "not authenticated yet", which is the
|
||||
// correct reading of an empty path.
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(fields[sessionField])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stored session at %s is not decodable; re-run "+
|
||||
"`provision session bootstrap`", s.Store.Ref(KeyOperatorSession))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (s SessionStorage) StoreSession(ctx context.Context, data []byte) error {
|
||||
return s.Store.Put(ctx, KeyOperatorSession, map[string]string{
|
||||
sessionField: base64.StdEncoding.EncodeToString(data),
|
||||
})
|
||||
}
|
||||
|
||||
// AppCredentials are issued by my.telegram.org and cannot be provisioned; see
|
||||
// docs/seeding-runbook.md step 2.
|
||||
type AppCredentials struct {
|
||||
AppID int
|
||||
AppHash string
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue