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,230 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user