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,186 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// owServer is an in-process SSH server emulating an OpenWrt router: it authenticates
|
||||
// password logins against a mutable current password, and handles a `passwd` exec by
|
||||
// reading the new password (twice) from the session stdin and updating that current
|
||||
// password — so a test can prove the cutover (old password stops authenticating).
|
||||
type owServer struct {
|
||||
mu sync.Mutex
|
||||
cur string
|
||||
}
|
||||
|
||||
func (o *owServer) password() string {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
return o.cur
|
||||
}
|
||||
|
||||
func (o *owServer) setPassword(p string) {
|
||||
o.mu.Lock()
|
||||
o.cur = p
|
||||
o.mu.Unlock()
|
||||
}
|
||||
|
||||
func startOpenWrtServer(t *testing.T, srv *owServer) 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
|
||||
}
|
||||
go srv.serve(conn, hostSigner)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
func (o *owServer) serve(conn net.Conn, hostKey ssh.Signer) {
|
||||
cfg := &ssh.ServerConfig{
|
||||
PasswordCallback: func(_ ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||
if string(password) == o.password() {
|
||||
return &ssh.Permissions{}, nil
|
||||
}
|
||||
return nil, errors.New("auth denied")
|
||||
},
|
||||
}
|
||||
cfg.AddHostKey(hostKey)
|
||||
sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
go ssh.DiscardRequests(reqs)
|
||||
for nc := range chans {
|
||||
if nc.ChannelType() != "session" {
|
||||
nc.Reject(ssh.UnknownChannelType, "only session")
|
||||
continue
|
||||
}
|
||||
ch, requests, err := nc.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go o.handleSession(ch, requests)
|
||||
}
|
||||
_ = sshConn
|
||||
}
|
||||
|
||||
func (o *owServer) handleSession(ch ssh.Channel, requests <-chan *ssh.Request) {
|
||||
for req := range requests {
|
||||
if req.Type != "exec" {
|
||||
req.Reply(false, nil)
|
||||
continue
|
||||
}
|
||||
var payload struct{ Command string }
|
||||
ssh.Unmarshal(req.Payload, &payload)
|
||||
req.Reply(true, nil)
|
||||
if strings.HasPrefix(payload.Command, "passwd") {
|
||||
rd := bufio.NewReader(ch)
|
||||
line1, _ := rd.ReadString('\n')
|
||||
rd.ReadString('\n') // retype (ignored; equals line1)
|
||||
o.setPassword(strings.TrimSpace(line1))
|
||||
} else {
|
||||
io.WriteString(ch, "incredigo-ok\n")
|
||||
}
|
||||
ch.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{0}))
|
||||
ch.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenWrtDetect(t *testing.T) {
|
||||
o := &OpenWrt{}
|
||||
if !o.Detect(discover.Credential{Source: "openwrt"}) {
|
||||
t.Error("should detect Source=openwrt")
|
||||
}
|
||||
if o.Detect(discover.Credential{Source: "ssh"}) {
|
||||
t.Error("should not detect Source=ssh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenWrtRotateRealCutover(t *testing.T) {
|
||||
srv := &owServer{cur: "oldpw"}
|
||||
addr := startOpenWrtServer(t, srv)
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
cred := discover.Credential{
|
||||
Source: "openwrt",
|
||||
Identity: "router",
|
||||
Secret: v.Store([]byte("openwrt://root:oldpw@" + addr + "/")),
|
||||
}
|
||||
o := &OpenWrt{}
|
||||
ctx := context.Background()
|
||||
|
||||
newH, err := o.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate: %v", err)
|
||||
}
|
||||
if srv.password() == "oldpw" {
|
||||
t.Fatal("admin password not changed by passwd")
|
||||
}
|
||||
|
||||
if err := o.Verify(ctx, newH, v); err != nil {
|
||||
t.Errorf("Verify(new) should pass: %v", err)
|
||||
}
|
||||
|
||||
// Extract the new password to assert it never leaks into an error string.
|
||||
nb, _ := v.Open(newH)
|
||||
nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes())))
|
||||
newPw, _ := nu.User.Password()
|
||||
if newPw == "oldpw" || newPw == "" {
|
||||
t.Fatal("new credential carries no fresh password")
|
||||
}
|
||||
|
||||
if err := o.Verify(ctx, cred.Secret, v); err == nil {
|
||||
t.Error("Verify(old) must fail after rotation — old password should be revoked")
|
||||
} else if strings.Contains(err.Error(), newPw) {
|
||||
t.Error("error leaked the new password")
|
||||
}
|
||||
|
||||
if err := o.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Errorf("RevokeOld (no-op) returned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenWrtRejectsBadSecret(t *testing.T) {
|
||||
o := &OpenWrt{}
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
cred := discover.Credential{Source: "openwrt", Secret: v.Store([]byte("openwrt://root@192.168.1.1/"))} // no password
|
||||
if _, err := o.Rotate(context.Background(), cred, v); err == nil {
|
||||
t.Error("Rotate should reject a secret with no password")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user