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>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user