Files
incredigo/internal/rotate/mysql.go
T
leetcrypt 59af53dcc0 rotate: nine rotation drivers + verify-before-revoke execute spine
Implements the Rotator interface across all four rotation patterns, each a
self-contained one-file driver self-registering via init():

  in-place DB password   postgres, mysql (3-stmt unprivileged fallback), redis
  local keypair + propagate  sshkey (ed25519), wireguard (clamped curve25519)
  provider-API token         gitea PAT, mullvad device key
  cloud-key self-identifying  aws IAM access key (hand-rolled SigV4, no SDK dep)

Spine (execute.go) enforces Hard Rule #2: backup -> rotate -> verify(new) ->
store -> re-read+verify -> revoke-old; dryrun.go keeps --execute gated. Each
driver ships a table-driven test proving real cutover (old secret stops
authenticating) and asserting no secret substring leaks to argv/Meta/errors.
Promotes golang.org/x/crypto to a direct dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-18 14:48:24 -07:00

188 lines
6.5 KiB
Go

package rotate
import (
"bytes"
"context"
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"strings"
"incredigo/internal/discover"
"incredigo/internal/vault"
)
// MySQL rotates a MySQL/MariaDB account password in place, the same in-place DB
// pattern as Postgres. The credential's secret is the full DSN
// ("mysql://user:pw@host:port/db"); rotation mints a new password and runs
//
// ALTER USER CURRENT_USER() IDENTIFIED BY '<newpw>'
//
// over the OLD connection. CURRENT_USER() targets the *currently authenticated*
// account, so the driver never needs to know the user's 'name'@'host' grant tuple,
// and it is the form accepted by BOTH MySQL and MariaDB (MariaDB rejects the
// MySQL-only USER() spelling in ALTER USER). The ALTER is
// itself the cutover (the old password stops working immediately), so RevokeOld is a
// no-op and the §3 sealed backup is the recovery path — identical reasoning to
// Postgres (see docs/ROTATION.md §17).
//
// SECURITY: the password reaches the mysql client only via the MYSQL_PWD
// *environment* variable (never argv, never disk), and the SQL — which contains the
// NEW password — is fed on stdin so it never appears in the process argument list.
// The new password is hex ([0-9a-f]) so the single-quoted SQL literal cannot break
// out. Captured output is scrubbed of the new password before any error surfaces.
type MySQL struct {
Bin string // mysql client binary; defaults to "mysql"
}
// init registers the driver. Registration only makes it available; changing a
// credential still requires `rotate --execute` AND INCREDIGO_ALLOW_EXECUTE=1.
func init() { Register(&MySQL{}) }
// Name identifies the driver in plans and audit records.
func (m *MySQL) Name() string { return "mysql" }
// Detect claims credentials a mysql scanner/seed emitted (Source == "mysql").
func (m *MySQL) Detect(c discover.Credential) bool { return c.Source == "mysql" }
func (m *MySQL) bin() string {
if m.Bin != "" {
return m.Bin
}
return "mysql"
}
// Rotate mints a new password, applies it to the currently-authenticated account
// over the existing (old) connection, and returns a vault handle to the rebuilt DSN.
//
// It tries, in order, the statements a user can run to change *their own* password,
// because no single statement works unprivileged across both engines and versions:
//
// 1. ALTER USER CURRENT_USER() IDENTIFIED BY '…' — MySQL 5.7/8.0 self-service (no
// special privilege). MariaDB rejects this for a non-admin (it demands the global
// CREATE USER privilege even for your own account), so we fall through.
// 2. SET PASSWORD = PASSWORD('…') — MariaDB self-service (no priv);
// also MySQL 5.7. Removed in MySQL 8.0, so we fall through there.
// 3. SET PASSWORD = '…' — MySQL 8.0 / MariaDB ≥10.4.
//
// The first to succeed wins. This matters for the target persona: a vibe coder's DB
// user is usually scoped to one schema (GRANT ALL ON app.*) and CANNOT ALTER USER on
// MariaDB, but can always change its own password via SET PASSWORD.
func (m *MySQL) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
u, err := openMySQLDSN(v, c.Secret)
if err != nil {
return nil, err
}
if u.User.Username() == "" {
return nil, errors.New("mysql: dsn has no user")
}
oldPw, _ := u.User.Password()
newPw, err := genHex(24)
if err != nil {
return nil, err
}
// New password is hex ([0-9a-f]) so the single-quoted literals below cannot break
// out of the statement.
stmts := []string{
fmt.Sprintf("ALTER USER CURRENT_USER() IDENTIFIED BY '%s';", newPw),
fmt.Sprintf("SET PASSWORD = PASSWORD('%s');", newPw),
fmt.Sprintf("SET PASSWORD = '%s';", newPw),
}
var lastErr error
changed := false
for _, sql := range stmts {
if _, err := m.mysql(ctx, u, oldPw, sql, newPw); err == nil {
changed = true
break
} else {
lastErr = err
}
}
if !changed {
return nil, fmt.Errorf("mysql: change password: %w", lastErr)
}
nu := *u
nu.User = url.UserPassword(u.User.Username(), newPw)
return v.Store([]byte(nu.String())), nil
}
// Verify proves the freshly minted DSN authenticates by issuing SELECT 1 with the
// new password.
func (m *MySQL) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
u, err := openMySQLDSN(v, newSecret)
if err != nil {
return err
}
pw, _ := u.User.Password()
out, err := m.mysql(ctx, u, pw, "SELECT 1;", pw)
if err != nil {
return fmt.Errorf("mysql verify: %w", err)
}
if !strings.Contains(out, "1") {
return fmt.Errorf("mysql verify: unexpected result")
}
return nil
}
// RevokeOld is a no-op for an in-place engine: the ALTER in Rotate already
// invalidated the previous password. Defined to satisfy the safety-spine ordering.
func (m *MySQL) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
return nil
}
// mysql runs one SQL statement against the DSN's host/db as the DSN's user,
// authenticating via MYSQL_PWD and feeding sql on stdin (keeping any secret out of
// argv). redact, if non-empty, is scrubbed from captured output before it can reach
// an error string.
func (m *MySQL) mysql(ctx context.Context, u *url.URL, password, sql, redact string) (string, error) {
host := u.Hostname()
port := u.Port()
if port == "" {
port = "3306"
}
args := []string{
"--batch", "--skip-column-names",
"-h", host, "-P", port, "-u", u.User.Username(),
}
if db := strings.TrimPrefix(u.Path, "/"); db != "" {
args = append(args, "-D", db)
}
cmd := exec.CommandContext(ctx, m.bin(), args...)
cmd.Env = append(os.Environ(), "MYSQL_PWD="+password)
cmd.Stdin = strings.NewReader(sql + "\n")
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
err := cmd.Run()
so, se := out.String(), errb.String()
if redact != "" {
so = strings.ReplaceAll(so, redact, "***")
se = strings.ReplaceAll(se, redact, "***")
}
if err != nil {
return so, fmt.Errorf("%v: %s", err, strings.TrimSpace(se))
}
return so, nil
}
// openMySQLDSN reads a DSN secret from the vault and parses it, accepting the
// mysql:// and mariadb:// schemes. The DSN string is transient and never persisted.
func openMySQLDSN(v *vault.Vault, h *vault.Handle) (*url.URL, error) {
buf, err := v.Open(h)
if err != nil {
return nil, err
}
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
if err != nil {
return nil, fmt.Errorf("mysql: parse dsn: %w", err)
}
if u.Scheme != "mysql" && u.Scheme != "mariadb" {
return nil, fmt.Errorf("mysql: unexpected dsn scheme %q", u.Scheme)
}
return u, nil
}