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>
181 lines
5.4 KiB
Go
181 lines
5.4 KiB
Go
package rotate
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// mullvadEmu is a minimal in-memory stand-in for the Mullvad app API: it exchanges a
|
|
// known account number for an access token, and stores per-account devices keyed by
|
|
// id with their registered (PUBLIC) key. It NEVER receives a private key — a leak
|
|
// check in the test asserts that.
|
|
type mullvadEmu struct {
|
|
mu sync.Mutex
|
|
account string
|
|
devices map[string]string // device id -> pubkey
|
|
bodies []string // every request body seen (for leak inspection)
|
|
}
|
|
|
|
func (e *mullvadEmu) handler() http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// auth: exchange account number for a token.
|
|
if r.Method == http.MethodPost && r.URL.Path == "/auth/v1/token" {
|
|
var in struct {
|
|
AccountNumber string `json:"account_number"`
|
|
}
|
|
json.NewDecoder(r.Body).Decode(&in)
|
|
if in.AccountNumber != e.account {
|
|
http.Error(w, "bad account", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]string{"access_token": "tok-" + in.AccountNumber})
|
|
return
|
|
}
|
|
// everything else needs Bearer tok-<account>.
|
|
if r.Header.Get("Authorization") != "Bearer tok-"+e.account {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
switch {
|
|
case r.Method == http.MethodPost && r.URL.Path == "/accounts/v1/devices":
|
|
var in struct {
|
|
Pubkey string `json:"pubkey"`
|
|
}
|
|
raw, _ := io.ReadAll(r.Body)
|
|
body := string(raw)
|
|
e.mu.Lock()
|
|
e.bodies = append(e.bodies, body)
|
|
json.Unmarshal(raw, &in)
|
|
id := "dev-new"
|
|
e.devices[id] = in.Pubkey
|
|
e.mu.Unlock()
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]string{"id": id, "pubkey": in.Pubkey})
|
|
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/accounts/v1/devices/"):
|
|
id := strings.TrimPrefix(r.URL.Path, "/accounts/v1/devices/")
|
|
e.mu.Lock()
|
|
pub, ok := e.devices[id]
|
|
e.mu.Unlock()
|
|
if !ok {
|
|
http.Error(w, "no device", http.StatusNotFound)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]string{"pubkey": pub})
|
|
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/accounts/v1/devices/"):
|
|
id := strings.TrimPrefix(r.URL.Path, "/accounts/v1/devices/")
|
|
e.mu.Lock()
|
|
_, ok := e.devices[id]
|
|
delete(e.devices, id)
|
|
e.mu.Unlock()
|
|
if !ok {
|
|
http.Error(w, "no device", http.StatusNotFound)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
default:
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMullvadDetect(t *testing.T) {
|
|
m := &Mullvad{}
|
|
if !m.Detect(discover.Credential{Source: "mullvad"}) {
|
|
t.Error("should detect Source=mullvad")
|
|
}
|
|
if m.Detect(discover.Credential{Source: "wireguard"}) {
|
|
t.Error("should not detect Source=wireguard")
|
|
}
|
|
}
|
|
|
|
func TestMullvadRotateRealCutover(t *testing.T) {
|
|
const account = "1234567890123456"
|
|
oldPriv, oldPub, err := genWGKey()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldPrivB64 := base64.StdEncoding.EncodeToString(oldPriv)
|
|
|
|
emu := &mullvadEmu{account: account, devices: map[string]string{"dev-old": oldPub}}
|
|
srv := httptest.NewTLSServer(emu.handler())
|
|
defer srv.Close()
|
|
|
|
// Seed the credential pointing at the TLS test server (https scheme), carrying
|
|
// the old device + old private key.
|
|
su, _ := url.Parse(srv.URL)
|
|
su.User = url.User(account)
|
|
su.Path = "/"
|
|
q := url.Values{}
|
|
q.Set("device", "dev-old")
|
|
q.Set("privkey", oldPrivB64)
|
|
su.RawQuery = q.Encode()
|
|
|
|
m := &Mullvad{HTTPClient: srv.Client()}
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
cred := discover.Credential{Source: "mullvad", Identity: "mullvad-device", Secret: v.Store([]byte(su.String()))}
|
|
ctx := context.Background()
|
|
|
|
newH, err := m.Rotate(ctx, cred, v)
|
|
if err != nil {
|
|
t.Fatalf("Rotate: %v", err)
|
|
}
|
|
|
|
nb, err := v.Open(newH)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(nb.Bytes()), "dev-old") {
|
|
t.Error("new credential should point at the new device, not dev-old")
|
|
}
|
|
|
|
if err := m.Verify(ctx, newH, v); err != nil {
|
|
t.Errorf("Verify(new) should pass: %v", err)
|
|
}
|
|
|
|
// Leak checks: the API only ever saw PUBLIC keys; the old private key must not
|
|
// appear in any request body nor in any stored device pubkey.
|
|
for _, body := range emu.bodies {
|
|
if strings.Contains(body, oldPrivB64) {
|
|
t.Error("a private key leaked into a request body")
|
|
}
|
|
}
|
|
for _, pub := range emu.devices {
|
|
if pub == oldPrivB64 {
|
|
t.Error("a private key was stored as a device pubkey")
|
|
}
|
|
}
|
|
|
|
// Cutover: revoke the OLD device, then Verify against the old credential must
|
|
// fail (its device is gone).
|
|
if err := m.RevokeOld(ctx, cred, v); err != nil {
|
|
t.Fatalf("RevokeOld: %v", err)
|
|
}
|
|
if err := m.Verify(ctx, cred.Secret, v); err == nil {
|
|
t.Error("Verify(old) must fail after RevokeOld — old device should be deleted")
|
|
} else if strings.Contains(err.Error(), oldPrivB64) || strings.Contains(err.Error(), account) {
|
|
t.Error("error leaked the account number or private key")
|
|
}
|
|
}
|
|
|
|
func TestMullvadRejectsBadSecret(t *testing.T) {
|
|
m := &Mullvad{HTTPClient: http.DefaultClient}
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
cred := discover.Credential{Source: "mullvad", Secret: v.Store([]byte("mullvad://acct@api/?device=d"))} // no privkey
|
|
if _, err := m.Rotate(context.Background(), cred, v); err == nil {
|
|
t.Error("Rotate should reject a secret with no privkey")
|
|
}
|
|
}
|