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>
263 lines
8.8 KiB
Go
263 lines
8.8 KiB
Go
package rotate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
|
|
"golang.org/x/crypto/curve25519"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// WireGuard rotates a self-hosted WireGuard interface key, the LOCAL keypair pattern
|
|
// (the same family as SSHKey). The credential's secret is the interface PRIVATE key
|
|
// (base64, 32 bytes — the value of a `[Interface] PrivateKey =` line). Rotation:
|
|
//
|
|
// 1. mint a fresh Curve25519 keypair entirely in memory;
|
|
// 2. apply the NEW private key to the local interface (`wg set <iface> private-key`),
|
|
// fed on stdin so it never lands in argv or on disk;
|
|
// 3. teach the PEER (the other end) to trust the NEW public key, carrying over the
|
|
// old peer's allowed-ips (`wg set <peer_iface> peer <newpub> allowed-ips …`);
|
|
// 4. return a vault handle to the NEW private key (base64) — never written to disk;
|
|
// 5. Verify proves the peer now trusts the new public key;
|
|
// 6. RevokeOld removes the OLD public key from the peer — 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 and used ENTIRELY in process; it reaches `wg`
|
|
// only via stdin (`wg set <iface> private-key /dev/stdin`), never argv, never a
|
|
// key file. Only PUBLIC keys appear on the peer/`wg set peer` argument list. A
|
|
// leak-check in the tests asserts no private-key material reaches any wg call's
|
|
// argv or the peer state.
|
|
//
|
|
// Targeting (Meta, with struct-field / sensible defaults), so this models the
|
|
// self-hosted single-host case (an interface and its single peer the operator owns);
|
|
// a client→server split would propagate step 3/6 to the server over SSH/API instead:
|
|
//
|
|
// Meta["wg_iface"] local interface whose key we rotate (default wg0)
|
|
// Meta["wg_peer_iface"] the peer interface that must trust our key (default wg1)
|
|
// Meta["wg_allowed_ips"] fallback allowed-ips if the peer has none for the old key
|
|
type WireGuard struct {
|
|
Bin string // wg binary; defaults to "wg"
|
|
Iface string // default wg0
|
|
PeerIface string // default wg1
|
|
AllowedIPs string // fallback allowed-ips for the re-added peer
|
|
|
|
// mu guards inFlight: Verify in the Execute spine receives no credential, so
|
|
// Rotate records what it just rotated and Verify reads it back. Execute is
|
|
// sequential; the mutex keeps the single in-flight target race-clean.
|
|
mu sync.Mutex
|
|
inFlight discover.Credential
|
|
}
|
|
|
|
// init registers the driver. Availability alone changes nothing; `rotate --execute`
|
|
// + INCREDIGO_ALLOW_EXECUTE=1 gate any real change.
|
|
func init() { Register(&WireGuard{}) }
|
|
|
|
// Name identifies the driver in plans and audit records.
|
|
func (w *WireGuard) Name() string { return "wireguard" }
|
|
|
|
// Detect claims credentials a wireguard scanner/seed emitted (Source == "wireguard").
|
|
func (w *WireGuard) Detect(c discover.Credential) bool { return c.Source == "wireguard" }
|
|
|
|
func (w *WireGuard) bin() string {
|
|
if w.Bin != "" {
|
|
return w.Bin
|
|
}
|
|
return "wg"
|
|
}
|
|
|
|
func (w *WireGuard) iface(c discover.Credential) string {
|
|
if i := c.Meta["wg_iface"]; i != "" {
|
|
return i
|
|
}
|
|
if w.Iface != "" {
|
|
return w.Iface
|
|
}
|
|
return "wg0"
|
|
}
|
|
|
|
func (w *WireGuard) peerIface(c discover.Credential) string {
|
|
if i := c.Meta["wg_peer_iface"]; i != "" {
|
|
return i
|
|
}
|
|
if w.PeerIface != "" {
|
|
return w.PeerIface
|
|
}
|
|
return "wg1"
|
|
}
|
|
|
|
func (w *WireGuard) allowedIPsFallback(c discover.Credential) string {
|
|
if a := c.Meta["wg_allowed_ips"]; a != "" {
|
|
return a
|
|
}
|
|
return w.AllowedIPs
|
|
}
|
|
|
|
// Rotate mints a new keypair in memory, applies the new private key to the local
|
|
// interface, teaches the peer to trust the new public key (carrying over the old
|
|
// peer's allowed-ips), and returns a vault handle to the new private key (base64).
|
|
// It does not remove the old key.
|
|
func (w *WireGuard) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
|
oldPub, err := w.pubOf(v, c.Secret)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wireguard: old key unusable: %w", err)
|
|
}
|
|
|
|
newPriv, newPub, err := genWGKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wireguard: generate key: %w", err)
|
|
}
|
|
|
|
// Carry over the allowed-ips the peer currently routes to our OLD key, so the
|
|
// re-added entry keeps the same routing. Fall back to Meta/struct if absent.
|
|
allowed := w.peerAllowedIPs(ctx, c, oldPub)
|
|
if allowed == "" {
|
|
allowed = w.allowedIPsFallback(c)
|
|
}
|
|
if allowed == "" {
|
|
zeroBytes(newPriv)
|
|
return nil, errors.New("wireguard: no allowed-ips for peer (set Meta wg_allowed_ips)")
|
|
}
|
|
|
|
// Apply the new private key to our interface via stdin — never argv, never disk.
|
|
newPrivB64 := base64.StdEncoding.EncodeToString(newPriv)
|
|
if _, err := w.wgStdin(ctx, newPrivB64+"\n", "set", w.iface(c), "private-key", "/dev/stdin"); err != nil {
|
|
zeroBytes(newPriv)
|
|
return nil, fmt.Errorf("wireguard: set interface key: %w", err)
|
|
}
|
|
|
|
// Teach the peer to trust the NEW public key (old still trusted until RevokeOld).
|
|
if _, err := w.wg(ctx, "set", w.peerIface(c), "peer", newPub, "allowed-ips", allowed); err != nil {
|
|
zeroBytes(newPriv)
|
|
return nil, fmt.Errorf("wireguard: add peer pubkey: %w", err)
|
|
}
|
|
|
|
w.mu.Lock()
|
|
w.inFlight = c
|
|
w.mu.Unlock()
|
|
|
|
h := v.Store([]byte(newPrivB64))
|
|
zeroBytes(newPriv)
|
|
return h, nil
|
|
}
|
|
|
|
// Verify proves the peer now trusts the public key derived from the new private key.
|
|
func (w *WireGuard) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
|
newPub, err := w.pubOf(v, newSecret)
|
|
if err != nil {
|
|
return fmt.Errorf("wireguard verify: %w", err)
|
|
}
|
|
w.mu.Lock()
|
|
c := w.inFlight
|
|
w.mu.Unlock()
|
|
|
|
out, err := w.wg(ctx, "show", w.peerIface(c), "peers")
|
|
if err != nil {
|
|
return fmt.Errorf("wireguard verify: %w", err)
|
|
}
|
|
if !strings.Contains(out, newPub) {
|
|
return errors.New("wireguard verify: peer does not trust the new public key")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeOld removes the OLD public key from the peer. It derives the public key from
|
|
// the old PRIVATE secret in the vault, so it removes exactly the peer entry the old
|
|
// key authenticated with.
|
|
func (w *WireGuard) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
|
oldPub, err := w.pubOf(v, c.Secret)
|
|
if err != nil {
|
|
return fmt.Errorf("wireguard revoke: old key unusable: %w", err)
|
|
}
|
|
if _, err := w.wg(ctx, "set", w.peerIface(c), "peer", oldPub, "remove"); err != nil {
|
|
return fmt.Errorf("wireguard revoke: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// peerAllowedIPs reads the allowed-ips the peer currently maps to pub, or "" if the
|
|
// peer has no entry for it. Output of `wg show <iface> allowed-ips` is one
|
|
// "<pubkey>\t<ip>[,<ip>...]" row per peer.
|
|
func (w *WireGuard) peerAllowedIPs(ctx context.Context, c discover.Credential, pub string) string {
|
|
out, err := w.wg(ctx, "show", w.peerIface(c), "allowed-ips")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) >= 2 && fields[0] == pub {
|
|
return fields[1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// pubOf reads a base64 WireGuard private key from the vault and returns its base64
|
|
// public key, keeping the private bytes out of any retained Go string.
|
|
func (w *WireGuard) pubOf(v *vault.Vault, h *vault.Handle) (string, error) {
|
|
if h == nil {
|
|
return "", errors.New("nil secret handle")
|
|
}
|
|
buf, err := v.Open(h)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return pubFromPrivB64(strings.TrimSpace(string(buf.Bytes())))
|
|
}
|
|
|
|
// wg runs `wg <args...>` with no stdin and returns trimmed stdout, redacting nothing
|
|
// because only public keys / interface names ever appear here.
|
|
func (w *WireGuard) wg(ctx context.Context, args ...string) (string, error) {
|
|
return w.wgStdin(ctx, "", args...)
|
|
}
|
|
|
|
// wgStdin runs `wg <args...>` feeding stdin (used to pass a private key to
|
|
// `set ... private-key /dev/stdin` without it touching argv or disk).
|
|
func (w *WireGuard) wgStdin(ctx context.Context, stdin string, args ...string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, w.bin(), args...)
|
|
if stdin != "" {
|
|
cmd.Stdin = strings.NewReader(stdin)
|
|
}
|
|
var out, errb bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &errb
|
|
if err := cmd.Run(); err != nil {
|
|
return out.String(), fmt.Errorf("%v: %s", err, strings.TrimSpace(errb.String()))
|
|
}
|
|
return strings.TrimSpace(out.String()), nil
|
|
}
|
|
|
|
// genWGKey mints a clamped Curve25519 private key and its public key, the way
|
|
// `wg genkey | wg pubkey` would, entirely in memory.
|
|
func genWGKey() (priv []byte, pubB64 string, err error) {
|
|
priv = make([]byte, 32)
|
|
if _, err = rand.Read(priv); err != nil {
|
|
return nil, "", err
|
|
}
|
|
// Curve25519 clamping (RFC 7748 §5).
|
|
priv[0] &= 248
|
|
priv[31] &= 127
|
|
priv[31] |= 64
|
|
pub, err := curve25519.X25519(priv, curve25519.Basepoint)
|
|
if err != nil {
|
|
zeroBytes(priv)
|
|
return nil, "", err
|
|
}
|
|
return priv, base64.StdEncoding.EncodeToString(pub), nil
|
|
}
|
|
|
|
func zeroBytes(b []byte) {
|
|
for i := range b {
|
|
b[i] = 0
|
|
}
|
|
}
|