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>
311 lines
10 KiB
Go
311 lines
10 KiB
Go
package rotate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// SSHKey rotates an SSH key pair by minting a brand-new keypair and swapping which
|
|
// PUBLIC key is trusted in an authorized_keys file. The credential's secret is the
|
|
// OpenSSH-format PRIVATE key (the whole key file). Rotation:
|
|
//
|
|
// 1. mint a fresh ed25519 keypair entirely in memory;
|
|
// 2. install the NEW public key into authorized_keys (old key still trusted);
|
|
// 3. return a vault handle to the NEW private key (PEM) — never written to disk;
|
|
// 4. Verify dials the SSH authority with the new key to prove it authenticates;
|
|
// 5. RevokeOld removes the OLD public key from authorized_keys — only after the
|
|
// new key is verified and stored (Hard Rule #2, verify-new-before-revoke-old).
|
|
//
|
|
// SECURITY (Hard Rule #3 — no plaintext secrets on disk, ever):
|
|
// - The new private key is generated, PEM-marshalled, parsed, and used to
|
|
// authenticate ENTIRELY in process (golang.org/x/crypto/ssh). It is handed to
|
|
// the vault and to gopass; it never touches the filesystem here. No external
|
|
// ssh-keygen, no temp key file.
|
|
// - Only PUBLIC keys are written to authorized_keys. A leak-check in the tests
|
|
// asserts no "PRIVATE KEY" material reaches authorized_keys.
|
|
//
|
|
// Targeting is the credential's Meta (so discovery can point it at the right
|
|
// authority) with struct-field / sensible defaults:
|
|
//
|
|
// Meta["ssh_addr"] host:port to dial (default 127.0.0.1:22)
|
|
// Meta["ssh_user"] login user (default current user)
|
|
// Meta["authorized_keys"] file to edit (default ~/.ssh/authorized_keys)
|
|
type SSHKey struct {
|
|
Addr string // default 127.0.0.1:22
|
|
User string // default current OS user
|
|
AuthKeysPath string // default ~/.ssh/authorized_keys
|
|
HostKey ssh.HostKeyCallback // default InsecureIgnoreHostKey (we own the host)
|
|
// Dial is injectable so tests can point at an in-process SSH server. Defaults
|
|
// to ssh.Dial.
|
|
Dial func(network, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error)
|
|
|
|
// mu guards inFlight: Verify in the Execute spine receives no credential, so
|
|
// Rotate records the credential it just rotated (for its Meta targeting) and
|
|
// Verify reads it back. Execute is sequential, so a single in-flight target is
|
|
// sufficient; the mutex keeps it race-clean under -race.
|
|
mu sync.Mutex
|
|
inFlight discover.Credential
|
|
}
|
|
|
|
// init registers the ssh driver (self-register, like database/sql). Availability
|
|
// alone changes nothing; `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate it.
|
|
func init() { Register(&SSHKey{}) }
|
|
|
|
// Name identifies the driver in plans and audit records.
|
|
func (s *SSHKey) Name() string { return "ssh" }
|
|
|
|
// Detect claims credentials the ssh scanner/seed emitted (Source == "ssh").
|
|
func (s *SSHKey) Detect(c discover.Credential) bool { return c.Source == "ssh" }
|
|
|
|
func (s *SSHKey) addr(c discover.Credential) string {
|
|
if a := c.Meta["ssh_addr"]; a != "" {
|
|
return a
|
|
}
|
|
if s.Addr != "" {
|
|
return s.Addr
|
|
}
|
|
return "127.0.0.1:22"
|
|
}
|
|
|
|
func (s *SSHKey) loginUser(c discover.Credential) string {
|
|
if u := c.Meta["ssh_user"]; u != "" {
|
|
return u
|
|
}
|
|
if s.User != "" {
|
|
return s.User
|
|
}
|
|
if u, err := user.Current(); err == nil {
|
|
return u.Username
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *SSHKey) authKeys(c discover.Credential) string {
|
|
if p := c.Meta["authorized_keys"]; p != "" {
|
|
return p
|
|
}
|
|
if s.AuthKeysPath != "" {
|
|
return s.AuthKeysPath
|
|
}
|
|
home, _ := os.UserHomeDir()
|
|
return filepath.Join(home, ".ssh", "authorized_keys")
|
|
}
|
|
|
|
func (s *SSHKey) hostKey() ssh.HostKeyCallback {
|
|
if s.HostKey != nil {
|
|
return s.HostKey
|
|
}
|
|
// We rotate keys for hosts the operator owns; the trust boundary here is the
|
|
// key auth, not host identity. Host-key pinning is a separate concern.
|
|
return ssh.InsecureIgnoreHostKey()
|
|
}
|
|
|
|
func (s *SSHKey) dial() func(string, string, *ssh.ClientConfig) (*ssh.Client, error) {
|
|
if s.Dial != nil {
|
|
return s.Dial
|
|
}
|
|
return ssh.Dial
|
|
}
|
|
|
|
// Rotate mints a new ed25519 keypair in memory, installs the new PUBLIC key into
|
|
// authorized_keys (leaving the old one trusted), and returns a vault handle to the
|
|
// new PRIVATE key in OpenSSH PEM form. It does not revoke the old key.
|
|
func (s *SSHKey) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
|
// Validate we can parse the OLD key first — refuse to rotate something we don't
|
|
// actually hold a usable secret for (encrypted keys without a passphrase, junk).
|
|
if _, err := s.signer(v, c.Secret); err != nil {
|
|
return nil, fmt.Errorf("ssh: old key unusable: %w", err)
|
|
}
|
|
|
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ssh: generate key: %w", err)
|
|
}
|
|
|
|
comment := fmt.Sprintf("incredigo-rotated-%s-%d", c.Identity, time.Now().Unix())
|
|
block, err := ssh.MarshalPrivateKey(priv, comment)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ssh: marshal private key: %w", err)
|
|
}
|
|
privPEM := pem.EncodeToMemory(block)
|
|
|
|
sshPub, err := ssh.NewPublicKey(pub)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ssh: new public key: %w", err)
|
|
}
|
|
newLine := ssh.MarshalAuthorizedKey(sshPub) // "<type> <base64> ...\n"
|
|
// strip generated comment-less trailing and re-append with our comment for
|
|
// human readability in authorized_keys (the blob is unchanged).
|
|
newLine = append(bytes.TrimRight(newLine, "\n"), []byte(" "+comment+"\n")...)
|
|
|
|
if err := appendAuthorizedKey(s.authKeys(c), newLine); err != nil {
|
|
return nil, fmt.Errorf("ssh: install new public key: %w", err)
|
|
}
|
|
|
|
// Record this credential's targeting for the credential-less Verify that
|
|
// Execute calls next.
|
|
s.mu.Lock()
|
|
s.inFlight = c
|
|
s.mu.Unlock()
|
|
|
|
// Hand the new private key to the vault; Store wipes privPEM.
|
|
return v.Store(privPEM), nil
|
|
}
|
|
|
|
// Verify proves the newly minted private key authenticates by dialing the SSH
|
|
// authority and opening a session-less connection.
|
|
func (s *SSHKey) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
|
signer, err := s.signer(v, newSecret)
|
|
if err != nil {
|
|
return fmt.Errorf("ssh verify: %w", err)
|
|
}
|
|
s.mu.Lock()
|
|
c := s.inFlight
|
|
s.mu.Unlock()
|
|
return s.dialWith(ctx, c, signer)
|
|
}
|
|
|
|
// dialWith opens (and immediately closes) an SSH connection authenticated by
|
|
// signer. The credential supplies targeting Meta; an empty Credential uses
|
|
// struct/default targeting (Verify passes one). It uses the resolved addr/user.
|
|
func (s *SSHKey) dialWith(ctx context.Context, c discover.Credential, signer ssh.Signer) error {
|
|
cfg := &ssh.ClientConfig{
|
|
User: s.loginUser(c),
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
HostKeyCallback: s.hostKey(),
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
cli, err := s.dial()("tcp", s.addr(c), cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("ssh dial %s: %w", s.addr(c), err)
|
|
}
|
|
_ = cli.Close()
|
|
return nil
|
|
}
|
|
|
|
// RevokeOld removes the OLD public key from authorized_keys. It derives the public
|
|
// key from the old PRIVATE secret in the vault, so it removes exactly the line the
|
|
// old key would authenticate with (compared by the base64 key blob, ignoring
|
|
// options/comment differences).
|
|
func (s *SSHKey) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
|
signer, err := s.signer(v, c.Secret)
|
|
if err != nil {
|
|
return fmt.Errorf("ssh revoke: old key unusable: %w", err)
|
|
}
|
|
oldLine := ssh.MarshalAuthorizedKey(signer.PublicKey())
|
|
return removeAuthorizedKey(s.authKeys(c), oldLine)
|
|
}
|
|
|
|
// signer reads an OpenSSH private key from the vault and parses it into a Signer,
|
|
// keeping the key material out of any Go string.
|
|
func (s *SSHKey) signer(v *vault.Vault, h *vault.Handle) (ssh.Signer, error) {
|
|
if h == nil {
|
|
return nil, errors.New("nil secret handle")
|
|
}
|
|
buf, err := v.Open(h)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
signer, err := ssh.ParsePrivateKey(buf.Bytes())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse private key: %w", err)
|
|
}
|
|
return signer, nil
|
|
}
|
|
|
|
// authKeyBlob returns the base64 key field (the 2nd whitespace-separated token) of
|
|
// an authorized_keys line, which uniquely identifies the key independent of any
|
|
// leading options or trailing comment. Returns nil if the line has no key blob.
|
|
func authKeyBlob(line []byte) []byte {
|
|
line = bytes.TrimSpace(line)
|
|
if len(line) == 0 || line[0] == '#' {
|
|
return nil
|
|
}
|
|
fields := bytes.Fields(line)
|
|
// Plain form: "<type> <base64> [comment]". Options form:
|
|
// "<options> <type> <base64> [comment]". The key blob is the long base64
|
|
// token that starts with the SSH key-type magic ("AAAA"); find it robustly.
|
|
for _, f := range fields {
|
|
if bytes.HasPrefix(f, []byte("AAAA")) {
|
|
return f
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// appendAuthorizedKey adds line to the authorized_keys file if an entry with the
|
|
// same key blob is not already present. It creates ~/.ssh (0700) and the file
|
|
// (0600) as needed. line must end in a newline.
|
|
func appendAuthorizedKey(path string, line []byte) error {
|
|
want := authKeyBlob(line)
|
|
if want == nil {
|
|
return errors.New("authorized_keys: refusing to write a line with no key blob")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
if existing, err := os.ReadFile(path); err == nil {
|
|
for _, l := range bytes.Split(existing, []byte("\n")) {
|
|
if bytes.Equal(authKeyBlob(l), want) {
|
|
return nil // already trusted — idempotent
|
|
}
|
|
}
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
if !bytes.HasSuffix(line, []byte("\n")) {
|
|
line = append(line, '\n')
|
|
}
|
|
_, err = f.Write(line)
|
|
return err
|
|
}
|
|
|
|
// removeAuthorizedKey rewrites authorized_keys without any line whose key blob
|
|
// matches line's, via a temp file + atomic rename so a crash never leaves a
|
|
// truncated file (which could lock the operator out).
|
|
func removeAuthorizedKey(path string, line []byte) error {
|
|
target := authKeyBlob(line)
|
|
if target == nil {
|
|
return errors.New("authorized_keys: cannot revoke a line with no key blob")
|
|
}
|
|
existing, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil // nothing to revoke
|
|
}
|
|
return err
|
|
}
|
|
var keep [][]byte
|
|
for _, l := range bytes.Split(existing, []byte("\n")) {
|
|
if bytes.Equal(authKeyBlob(l), target) {
|
|
continue // drop the old key
|
|
}
|
|
keep = append(keep, l)
|
|
}
|
|
out := bytes.Join(keep, []byte("\n"))
|
|
|
|
tmp := path + ".incredigo.tmp"
|
|
if err := os.WriteFile(tmp, out, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, path)
|
|
}
|