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>
291 lines
9.6 KiB
Go
291 lines
9.6 KiB
Go
package rotate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/curve25519"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// Mullvad rotates a Mullvad WireGuard device key via the Mullvad app API — the
|
|
// provider-API rotation pattern (create-new → verify → revoke-old). A Mullvad
|
|
// account holds up to N "devices", each a WireGuard keypair; rotation mints a fresh
|
|
// keypair, REGISTERS it as a new device, verifies it, then DELETES the old device.
|
|
//
|
|
// The credential's secret is a single self-contained line (so it round-trips through
|
|
// the single-line gopass sink unchanged):
|
|
//
|
|
// mullvad://<account_number>@<api-host>/?device=<device_id>&privkey=<base64-privkey>
|
|
//
|
|
// - <account_number> the Mullvad account number — the sole auth secret. Exchanged
|
|
// for a short-lived access token (POST /auth/v1/token); the
|
|
// account number itself never goes in a Bearer header.
|
|
// - <device_id> the device this credential represents (DELETE target on
|
|
// revoke; the new credential carries the NEW device's id).
|
|
// - privkey= the device's WireGuard PRIVATE key (base64). Only the derived
|
|
// PUBLIC key is ever sent to the API; the private key stays in
|
|
// the vault blob.
|
|
//
|
|
// SECURITY: account number, private key, and access token live only in the vault and
|
|
// in-process; none reach Identity/Meta/logs/argv. This driver is unit-tested against
|
|
// an httptest emulator only — it never authenticates to api.mullvad.net in tests
|
|
// (that needs a real paid account).
|
|
type Mullvad struct {
|
|
// HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout
|
|
// client using http.DefaultTransport.
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
// init self-registers the driver. Availability alone changes nothing; the
|
|
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
|
|
func init() { Register(&Mullvad{}) }
|
|
|
|
// Name identifies the driver in plans and audit records.
|
|
func (m *Mullvad) Name() string { return "mullvad" }
|
|
|
|
// Detect claims credentials tagged Source == "mullvad".
|
|
func (m *Mullvad) Detect(c discover.Credential) bool { return c.Source == "mullvad" }
|
|
|
|
func (m *Mullvad) client() *http.Client {
|
|
if m.HTTPClient != nil {
|
|
return m.HTTPClient
|
|
}
|
|
return &http.Client{Timeout: 15 * time.Second}
|
|
}
|
|
|
|
// mullvadSecret is the parsed credential blob. None of its fields are ever logged.
|
|
type mullvadSecret struct {
|
|
base string // scheme://host
|
|
account string
|
|
device string
|
|
privB64 string
|
|
}
|
|
|
|
func parseMullvadSecret(v *vault.Vault, h *vault.Handle) (mullvadSecret, error) {
|
|
buf, err := v.Open(h)
|
|
if err != nil {
|
|
return mullvadSecret{}, err
|
|
}
|
|
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
|
|
if err != nil {
|
|
return mullvadSecret{}, fmt.Errorf("mullvad: parse secret url: %w", err)
|
|
}
|
|
if u.Scheme != "mullvad" && u.Scheme != "http" && u.Scheme != "https" {
|
|
return mullvadSecret{}, fmt.Errorf("mullvad: unexpected scheme %q", u.Scheme)
|
|
}
|
|
if u.User == nil || u.User.Username() == "" {
|
|
return mullvadSecret{}, fmt.Errorf("mullvad: secret has no account number")
|
|
}
|
|
q := u.Query()
|
|
scheme := u.Scheme
|
|
if scheme == "mullvad" {
|
|
scheme = "https"
|
|
}
|
|
s := mullvadSecret{
|
|
base: scheme + "://" + u.Host,
|
|
account: u.User.Username(),
|
|
device: q.Get("device"),
|
|
privB64: q.Get("privkey"),
|
|
}
|
|
if s.privB64 == "" {
|
|
return mullvadSecret{}, fmt.Errorf("mullvad: secret has no privkey")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// build re-encodes a mullvadSecret into the single-line blob form (preserving the
|
|
// original scheme as "mullvad" so the in-vault form stays stable).
|
|
func (s mullvadSecret) build() string {
|
|
u := &url.URL{
|
|
Scheme: "mullvad",
|
|
Host: hostOf(s.base),
|
|
User: url.User(s.account),
|
|
Path: "/",
|
|
}
|
|
q := url.Values{}
|
|
q.Set("device", s.device)
|
|
q.Set("privkey", s.privB64)
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
// accessToken exchanges the account number for a short-lived API access token.
|
|
func (m *Mullvad) accessToken(ctx context.Context, s mullvadSecret) (string, error) {
|
|
body, _ := json.Marshal(map[string]string{"account_number": s.account})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/auth/v1/token", bytes.NewReader(body))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := m.client().Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("mullvad: auth: %w", err)
|
|
}
|
|
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
return "", fmt.Errorf("mullvad: auth: status %s", resp.Status)
|
|
}
|
|
var tok struct {
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
|
|
return "", fmt.Errorf("mullvad: decode auth: %w", err)
|
|
}
|
|
if tok.AccessToken == "" {
|
|
return "", fmt.Errorf("mullvad: auth: empty access token")
|
|
}
|
|
return tok.AccessToken, nil
|
|
}
|
|
|
|
// Rotate mints a new WireGuard keypair, registers it as a NEW device, and returns a
|
|
// vault handle to a rebuilt blob carrying the new device id + new private key. It
|
|
// does not delete the old device.
|
|
func (m *Mullvad) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
|
|
s, err := parseMullvadSecret(v, c.Secret)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
token, err := m.accessToken(ctx, s)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
newPriv, newPub, err := genWGKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mullvad: generate key: %w", err)
|
|
}
|
|
newPrivB64 := base64.StdEncoding.EncodeToString(newPriv)
|
|
zeroBytes(newPriv)
|
|
|
|
body, _ := json.Marshal(map[string]string{"pubkey": newPub})
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/accounts/v1/devices", bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := m.client().Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mullvad: create device: %w", err)
|
|
}
|
|
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("mullvad: create device: status %s", resp.Status)
|
|
}
|
|
var dev struct {
|
|
ID string `json:"id"`
|
|
Pubkey string `json:"pubkey"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
|
|
return nil, fmt.Errorf("mullvad: decode device: %w", err)
|
|
}
|
|
if dev.ID == "" {
|
|
return nil, fmt.Errorf("mullvad: create device: empty device id")
|
|
}
|
|
|
|
ns := s // carry base/account forward; swap device + key
|
|
ns.device = dev.ID
|
|
ns.privB64 = newPrivB64
|
|
return v.Store([]byte(ns.build())), nil
|
|
}
|
|
|
|
// Verify proves the new device exists carrying the public key derived from the new
|
|
// private key.
|
|
func (m *Mullvad) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
|
|
s, err := parseMullvadSecret(v, newSecret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wantPub, err := pubFromPrivB64(s.privB64)
|
|
if err != nil {
|
|
return fmt.Errorf("mullvad verify: %w", err)
|
|
}
|
|
token, err := m.accessToken(ctx, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/accounts/v1/devices/"+url.PathEscape(s.device), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := m.client().Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("mullvad verify: %w", err)
|
|
}
|
|
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("mullvad verify: get device status %s", resp.Status)
|
|
}
|
|
var dev struct {
|
|
Pubkey string `json:"pubkey"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
|
|
return fmt.Errorf("mullvad verify: decode device: %w", err)
|
|
}
|
|
if dev.Pubkey != wantPub {
|
|
return fmt.Errorf("mullvad verify: device pubkey does not match the new key")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RevokeOld deletes the OLD device. The new device is already registered+verified, so
|
|
// removing the old one closes its WireGuard key.
|
|
func (m *Mullvad) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
|
|
s, err := parseMullvadSecret(v, c.Secret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if s.device == "" {
|
|
return fmt.Errorf("mullvad revoke: old credential has no device id — remove it manually (guided)")
|
|
}
|
|
token, err := m.accessToken(ctx, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.base+"/accounts/v1/devices/"+url.PathEscape(s.device), nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := m.client().Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("mullvad revoke: %w", err)
|
|
}
|
|
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
|
|
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("mullvad revoke: delete device status %s", resp.Status)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// pubFromPrivB64 derives a base64 Curve25519 public key from a base64 private key,
|
|
// keeping the private bytes out of any retained string.
|
|
func pubFromPrivB64(privB64 string) (string, error) {
|
|
priv, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privB64))
|
|
if err != nil {
|
|
return "", fmt.Errorf("decode private key: %w", err)
|
|
}
|
|
if len(priv) != 32 {
|
|
zeroBytes(priv)
|
|
return "", fmt.Errorf("private key is %d bytes, want 32", len(priv))
|
|
}
|
|
pub, err := curve25519.X25519(priv, curve25519.Basepoint)
|
|
zeroBytes(priv)
|
|
if err != nil {
|
|
return "", fmt.Errorf("derive public key: %w", err)
|
|
}
|
|
return base64.StdEncoding.EncodeToString(pub), nil
|
|
}
|