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

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
}
}