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>
231 lines
6.1 KiB
Go
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
|
|
}
|