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) // " ...\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: " [comment]". Options form: // " [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) }