Files
incredigo/internal/rotate/sshkey_test.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

231 lines
6.1 KiB
Go

package rotate
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"net"
"os"
"path/filepath"
"testing"
"time"
"golang.org/x/crypto/ssh"
"incredigo/internal/discover"
"incredigo/internal/vault"
)
// TestSSHKeyRotateRealCutover proves the full SSH cutover against an in-process
// SSH server: the new key authenticates, the old key is dropped from
// authorized_keys only after verify, and no private-key material is ever written
// to authorized_keys.
func TestSSHKeyRotateRealCutover(t *testing.T) {
v := vault.New()
defer v.Purge()
authKeys := filepath.Join(t.TempDir(), "authorized_keys")
// Seed an OLD keypair: pubkey trusted in authorized_keys, private PEM in vault.
oldPub, oldPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen old key: %v", err)
}
oldSSHPub, err := ssh.NewPublicKey(oldPub)
if err != nil {
t.Fatalf("old ssh pub: %v", err)
}
if err := os.WriteFile(authKeys, ssh.MarshalAuthorizedKey(oldSSHPub), 0o600); err != nil {
t.Fatalf("seed authorized_keys: %v", err)
}
oldBlock, err := ssh.MarshalPrivateKey(oldPriv, "old")
if err != nil {
t.Fatalf("marshal old priv: %v", err)
}
oldPEM := pem.EncodeToMemory(oldBlock)
oldPEMCopy := append([]byte(nil), oldPEM...) // for later inequality check (Store wipes oldPEM)
oldSigner, err := ssh.NewSignerFromKey(oldPriv)
if err != nil {
t.Fatalf("old signer: %v", err)
}
addr := startSSHServer(t, authKeys)
cred := discover.Credential{
Source: "ssh",
Kind: discover.KindPrivateKey,
Identity: "id_ed25519",
Location: authKeys,
Secret: v.Store(oldPEM),
Meta: map[string]string{
"ssh_addr": addr,
"ssh_user": "tester",
"authorized_keys": authKeys,
},
}
// Baseline: the old key authenticates before any rotation.
if err := canAuth(addr, "tester", oldSigner); err != nil {
t.Fatalf("old key should authenticate at start: %v", err)
}
s := &SSHKey{}
ctx := context.Background()
// 1. Rotate — mint new key, install its pubkey (old still trusted).
newH, err := s.Rotate(ctx, cred, v)
if err != nil {
t.Fatalf("Rotate: %v", err)
}
nb, err := v.Open(newH)
if err != nil {
t.Fatalf("open new secret: %v", err)
}
if bytes.Equal(nb.Bytes(), oldPEMCopy) {
t.Fatal("new private key equals old — rotation minted nothing")
}
if !bytes.Contains(nb.Bytes(), []byte("PRIVATE KEY")) {
t.Fatal("new secret is not a private key PEM")
}
newSigner, err := ssh.ParsePrivateKey(nb.Bytes())
if err != nil {
t.Fatalf("parse new private key: %v", err)
}
// After Rotate both keys authenticate (verify-before-revoke ordering).
if err := canAuth(addr, "tester", oldSigner); err != nil {
t.Fatalf("old key must still work before revoke: %v", err)
}
if err := canAuth(addr, "tester", newSigner); err != nil {
t.Fatalf("new key must authenticate after install: %v", err)
}
// 2. Verify(new) via the driver.
if err := s.Verify(ctx, newH, v); err != nil {
t.Fatalf("Verify(new): %v", err)
}
// 3. RevokeOld — drop the old pubkey.
if err := s.RevokeOld(ctx, cred, v); err != nil {
t.Fatalf("RevokeOld: %v", err)
}
// Cutover assertions: old dead, new alive.
if err := canAuth(addr, "tester", oldSigner); err == nil {
t.Fatal("old key must be rejected after revoke")
}
if err := canAuth(addr, "tester", newSigner); err != nil {
t.Fatalf("new key must still authenticate after revoke: %v", err)
}
// Hard Rule #3: no private-key material in authorized_keys.
final, err := os.ReadFile(authKeys)
if err != nil {
t.Fatalf("read authorized_keys: %v", err)
}
if bytes.Contains(final, []byte("PRIVATE KEY")) {
t.Fatal("authorized_keys leaked private key material")
}
// Exactly one key should remain (the new one).
if got := bytes.Count(bytes.TrimSpace(final), []byte("ssh-ed25519")); got != 1 {
t.Fatalf("expected exactly 1 trusted key after cutover, found %d", got)
}
}
// canAuth dials addr and authenticates with signer, returning nil on success.
func canAuth(addr, user string, signer ssh.Signer) error {
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 5 * time.Second,
}
cli, err := ssh.Dial("tcp", addr, cfg)
if err != nil {
return err
}
return cli.Close()
}
// startSSHServer launches an in-process SSH server on a random localhost port that
// authorizes any public key currently present in authKeysPath (re-read per attempt,
// so it reflects rotation). It returns the listen address; the listener is closed
// at test cleanup.
func startSSHServer(t *testing.T, authKeysPath string) string {
t.Helper()
_, hostPriv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("gen host key: %v", err)
}
hostSigner, err := ssh.NewSignerFromKey(hostPriv)
if err != nil {
t.Fatalf("host signer: %v", err)
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return // listener closed
}
go serveSSH(conn, hostSigner, authKeysPath)
}
}()
return ln.Addr().String()
}
func serveSSH(conn net.Conn, hostKey ssh.Signer, authKeysPath string) {
cfg := &ssh.ServerConfig{
PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
if keyAuthorized(authKeysPath, key) {
return &ssh.Permissions{}, nil
}
return nil, ssh.ErrNoAuth
},
}
cfg.AddHostKey(hostKey)
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
if err != nil {
conn.Close()
return
}
go ssh.DiscardRequests(reqs)
go func() {
for ch := range chans {
ch.Reject(ssh.Prohibited, "no channels")
}
}()
_ = sshConn
}
// keyAuthorized reports whether key matches any entry in authKeysPath.
func keyAuthorized(path string, key ssh.PublicKey) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
want := key.Marshal()
for len(data) > 0 {
pub, _, _, rest, err := ssh.ParseAuthorizedKey(data)
if err != nil {
break
}
if bytes.Equal(pub.Marshal(), want) {
return true
}
data = rest
}
return false
}