59af53dcc0
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>
188 lines
6.5 KiB
Go
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
|
|
}
|