From 59af53dcc08c10cf5f40b26c0692599e2ecacbb2 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Thu, 18 Jun 2026 14:48:24 -0700 Subject: [PATCH] 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 --- go.mod | 2 +- internal/rotate/aws.go | 339 ++++++++++++++++++++++++++++++ internal/rotate/aws_test.go | 222 +++++++++++++++++++ internal/rotate/dryrun.go | 65 ++++++ internal/rotate/dryrun_test.go | 74 +++++++ internal/rotate/execute.go | 108 ++++++++++ internal/rotate/gitea.go | 256 ++++++++++++++++++++++ internal/rotate/gitea_test.go | 198 +++++++++++++++++ internal/rotate/mullvad.go | 290 +++++++++++++++++++++++++ internal/rotate/mullvad_test.go | 180 ++++++++++++++++ internal/rotate/mysql.go | 187 ++++++++++++++++ internal/rotate/mysql_test.go | 99 +++++++++ internal/rotate/noop.go | 80 +++++++ internal/rotate/noop_test.go | 117 +++++++++++ internal/rotate/openwrt.go | 214 +++++++++++++++++++ internal/rotate/openwrt_test.go | 186 ++++++++++++++++ internal/rotate/postgres.go | 176 ++++++++++++++++ internal/rotate/postgres_test.go | 104 +++++++++ internal/rotate/redis.go | 163 ++++++++++++++ internal/rotate/redis_test.go | 102 +++++++++ internal/rotate/rotate.go | 7 +- internal/rotate/sshkey.go | 310 +++++++++++++++++++++++++++ internal/rotate/sshkey_test.go | 230 ++++++++++++++++++++ internal/rotate/wireguard.go | 262 +++++++++++++++++++++++ internal/rotate/wireguard_test.go | 148 +++++++++++++ 25 files changed, 4116 insertions(+), 3 deletions(-) create mode 100644 internal/rotate/aws.go create mode 100644 internal/rotate/aws_test.go create mode 100644 internal/rotate/dryrun.go create mode 100644 internal/rotate/dryrun_test.go create mode 100644 internal/rotate/execute.go create mode 100644 internal/rotate/gitea.go create mode 100644 internal/rotate/gitea_test.go create mode 100644 internal/rotate/mullvad.go create mode 100644 internal/rotate/mullvad_test.go create mode 100644 internal/rotate/mysql.go create mode 100644 internal/rotate/mysql_test.go create mode 100644 internal/rotate/noop.go create mode 100644 internal/rotate/noop_test.go create mode 100644 internal/rotate/openwrt.go create mode 100644 internal/rotate/openwrt_test.go create mode 100644 internal/rotate/postgres.go create mode 100644 internal/rotate/postgres_test.go create mode 100644 internal/rotate/redis.go create mode 100644 internal/rotate/redis_test.go create mode 100644 internal/rotate/sshkey.go create mode 100644 internal/rotate/sshkey_test.go create mode 100644 internal/rotate/wireguard.go create mode 100644 internal/rotate/wireguard_test.go diff --git a/go.mod b/go.mod index cfe5963..193b944 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/spf13/cobra v1.8.1 + golang.org/x/crypto v0.45.0 golang.org/x/term v0.37.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -32,7 +33,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.31.0 // indirect ) diff --git a/internal/rotate/aws.go b/internal/rotate/aws.go new file mode 100644 index 0000000..8c3947b --- /dev/null +++ b/internal/rotate/aws.go @@ -0,0 +1,339 @@ +package rotate + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// AWS rotates an AWS IAM access key via the IAM/STS Query APIs by CREATING a new +// access key, verifying it with STS GetCallerIdentity, then DELETING the old one — +// the provider-API rotation pattern (create-new → verify → revoke-old). +// +// This is the cleanest provider-API driver because the OLD credential is +// self-identifying: an access key's AccessKeyId is *non-secret* and travels in the +// secret blob, so RevokeOld deletes exactly the right key (DeleteAccessKey takes the +// AccessKeyId directly — no name-capture dance like Gitea needed). +// +// The credential's secret is a single self-contained line (so it round-trips +// through the existing single-line gopass sink unchanged): +// +// aws://:@aws/?region=[&endpoint=] +// +// - AccessKeyId / SecretAccessKey are the userinfo user/password. +// - region= signing region (default us-east-1). IAM is global and always +// signs us-east-1; region matters only for STS regional endpoints. +// - endpoint= OPTIONAL override of the API base (for a self-hosted moto/LocalStack +// in testing). Absent → real AWS (iam/sts .amazonaws.com). +// +// SECURITY: requests are signed with AWS SigV4 by HMAC chaining over the secret key +// (crypto/hmac); the secret never appears in argv, on disk, or in any header except +// as the derived signature. The new SecretAccessKey is returned by AWS only once, in +// the CreateAccessKey XML body — it is decoded straight into the vault and never +// logged. Error paths read only non-success bodies (AWS error XML, which carries no +// secret) and surface just the error Code, never a raw body. +type AWS struct { + // HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout + // client. + HTTPClient *http.Client + // now is injectable so tests can pin the SigV4 timestamp; defaults to time.Now. + now func() time.Time +} + +// init self-registers the driver. Availability alone changes nothing; the +// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies. +func init() { Register(&AWS{}) } + +// Name identifies the driver in plans and audit records. +func (a *AWS) Name() string { return "aws" } + +// Detect claims credentials tagged Source == "aws". +func (a *AWS) Detect(c discover.Credential) bool { return c.Source == "aws" } + +func (a *AWS) client() *http.Client { + if a.HTTPClient != nil { + return a.HTTPClient + } + return &http.Client{Timeout: 15 * time.Second} +} + +func (a *AWS) clock() time.Time { + if a.now != nil { + return a.now() + } + return time.Now() +} + +// awsSecret is the parsed credential blob. None of its fields are ever logged. +type awsSecret struct { + keyID string + secret string + region string + endpoint string // "" = real AWS +} + +// parseAWSSecret reads the single-line blob from the vault and decomposes it. +func parseAWSSecret(v *vault.Vault, h *vault.Handle) (awsSecret, error) { + buf, err := v.Open(h) + if err != nil { + return awsSecret{}, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return awsSecret{}, fmt.Errorf("aws: parse secret url: %w", err) + } + if u.Scheme != "aws" { + return awsSecret{}, fmt.Errorf("aws: unexpected scheme %q", u.Scheme) + } + if u.User == nil { + return awsSecret{}, fmt.Errorf("aws: secret has no AccessKeyId:SecretAccessKey") + } + sec, _ := u.User.Password() + if u.User.Username() == "" || sec == "" { + return awsSecret{}, fmt.Errorf("aws: secret missing key id or secret") + } + q := u.Query() + region := q.Get("region") + if region == "" { + region = "us-east-1" + } + return awsSecret{ + keyID: u.User.Username(), + secret: sec, + region: region, + endpoint: q.Get("endpoint"), + }, nil +} + +// build re-encodes an awsSecret into the single-line blob form. +func (s awsSecret) build() string { + u := &url.URL{ + Scheme: "aws", + Host: "aws", + User: url.UserPassword(s.keyID, s.secret), + Path: "/", + } + q := url.Values{} + q.Set("region", s.region) + if s.endpoint != "" { + q.Set("endpoint", s.endpoint) + } + u.RawQuery = q.Encode() + return u.String() +} + +// endpointFor resolves the API base for a service ("iam" or "sts"), honoring an +// explicit endpoint override (moto/LocalStack) when set. +func (a *AWS) endpointFor(s awsSecret, service string) string { + if s.endpoint != "" { + return strings.TrimRight(s.endpoint, "/") + } + return "https://" + service + ".amazonaws.com" +} + +// Rotate creates a NEW access key for the calling identity (authenticated by the +// OLD key) and returns a vault handle to a rebuilt blob carrying the new key pair. +// It does not delete the old key. +func (a *AWS) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + s, err := parseAWSSecret(v, c.Secret) + if err != nil { + return nil, err + } + body, err := a.call(ctx, s, "iam", "CreateAccessKey", "2010-05-08", nil) + if err != nil { + return nil, fmt.Errorf("aws: create access key: %w", err) + } + var r struct { + KeyID string `xml:"CreateAccessKeyResult>AccessKey>AccessKeyId"` + Secret string `xml:"CreateAccessKeyResult>AccessKey>SecretAccessKey"` + } + if err := xml.Unmarshal(body, &r); err != nil { + return nil, fmt.Errorf("aws: decode created key: %w", err) + } + if r.KeyID == "" || r.Secret == "" { + return nil, fmt.Errorf("aws: create access key: empty key in response") + } + ns := s // carry region/endpoint forward + ns.keyID = r.KeyID + ns.secret = r.Secret + return v.Store([]byte(ns.build())), nil +} + +// Verify proves the freshly minted key authenticates by calling STS +// GetCallerIdentity with it and checking a caller ARN comes back. +func (a *AWS) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + s, err := parseAWSSecret(v, newSecret) + if err != nil { + return err + } + body, err := a.call(ctx, s, "sts", "GetCallerIdentity", "2011-06-15", nil) + if err != nil { + return fmt.Errorf("aws verify: %w", err) + } + var r struct { + Arn string `xml:"GetCallerIdentityResult>Arn"` + } + if err := xml.Unmarshal(body, &r); err != nil { + return fmt.Errorf("aws verify: decode caller identity: %w", err) + } + if r.Arn == "" { + return fmt.Errorf("aws verify: GetCallerIdentity returned no ARN") + } + return nil +} + +// RevokeOld deletes the OLD access key by its (non-secret) AccessKeyId. The old +// key is still valid at this point (revoke runs only after the new key is +// stored+verified), so it authenticates its own deletion. +func (a *AWS) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + s, err := parseAWSSecret(v, c.Secret) + if err != nil { + return err + } + extra := url.Values{} + extra.Set("AccessKeyId", s.keyID) + if _, err := a.call(ctx, s, "iam", "DeleteAccessKey", "2010-05-08", extra); err != nil { + return fmt.Errorf("aws revoke: %w", err) + } + return nil +} + +// call POSTs an AWS Query-protocol request (form-encoded Action/Version + extras), +// SigV4-signed with the blob's own key pair, and returns the response body. A +// non-2xx response yields an error carrying only the AWS error Code (never the +// raw body). +func (a *AWS) call(ctx context.Context, s awsSecret, service, action, version string, extra url.Values) ([]byte, error) { + form := url.Values{} + form.Set("Action", action) + form.Set("Version", version) + for k, vs := range extra { + for _, val := range vs { + form.Add(k, val) + } + } + body := []byte(form.Encode()) + + region := s.region + if service == "iam" { + region = "us-east-1" // IAM is a global service; SigV4 region is always us-east-1. + } + endpoint := a.endpointFor(s, service) + + hdrs, err := sigV4Headers(http.MethodPost, endpoint+"/", region, service, body, s.keyID, s.secret, a.clock()) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/", bytes.NewReader(body)) + if err != nil { + return nil, err + } + for k, val := range hdrs { + req.Header.Set(k, val) + } + + resp, err := a.client().Do(req) + if err != nil { + return nil, fmt.Errorf("%s: %w", action, err) + } + defer resp.Body.Close() + rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("%s: status %s: %s", action, resp.Status, awsErrCode(rb)) + } + return rb, nil +} + +// awsErrCode extracts the from an AWS error response so failures are +// legible without dumping the body. AWS error bodies never contain a secret. +func awsErrCode(b []byte) string { + var e struct { + Code string `xml:"Error>Code"` + } + if xml.Unmarshal(b, &e) == nil && e.Code != "" { + return e.Code + } + return "unknown error" +} + +// sigV4Headers computes the AWS Signature Version 4 headers for a POST request +// whose parameters are in the (already-encoded) body. Returns the headers the +// caller must set: Authorization, X-Amz-Date, X-Amz-Content-Sha256, Content-Type. +func sigV4Headers(method, rawURL, region, service string, body []byte, keyID, secret string, t time.Time) (map[string]string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, err + } + t = t.UTC() + amzDate := t.Format("20060102T150405Z") + dateStamp := t.Format("20060102") + contentType := "application/x-www-form-urlencoded; charset=utf-8" + payloadHash := sha256hex(body) + + canonicalURI := u.EscapedPath() + if canonicalURI == "" { + canonicalURI = "/" + } + canonicalHeaders := "content-type:" + contentType + "\n" + + "host:" + u.Host + "\n" + + "x-amz-content-sha256:" + payloadHash + "\n" + + "x-amz-date:" + amzDate + "\n" + signedHeaders := "content-type;host;x-amz-content-sha256;x-amz-date" + + canonicalRequest := strings.Join([]string{ + method, + canonicalURI, + u.RawQuery, // empty: params are in the body + canonicalHeaders, + signedHeaders, + payloadHash, + }, "\n") + + scope := dateStamp + "/" + region + "/" + service + "/aws4_request" + stringToSign := strings.Join([]string{ + "AWS4-HMAC-SHA256", + amzDate, + scope, + sha256hex([]byte(canonicalRequest)), + }, "\n") + + kDate := hmacSHA256([]byte("AWS4"+secret), dateStamp) + kRegion := hmacSHA256(kDate, region) + kService := hmacSHA256(kRegion, service) + kSigning := hmacSHA256(kService, "aws4_request") + signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign)) + + auth := "AWS4-HMAC-SHA256 " + + "Credential=" + keyID + "/" + scope + ", " + + "SignedHeaders=" + signedHeaders + ", " + + "Signature=" + signature + + return map[string]string{ + "Authorization": auth, + "X-Amz-Date": amzDate, + "X-Amz-Content-Sha256": payloadHash, + "Content-Type": contentType, + }, nil +} + +func sha256hex(b []byte) string { + h := sha256.Sum256(b) + return hex.EncodeToString(h[:]) +} + +func hmacSHA256(key []byte, data string) []byte { + m := hmac.New(sha256.New, key) + m.Write([]byte(data)) + return m.Sum(nil) +} diff --git a/internal/rotate/aws_test.go b/internal/rotate/aws_test.go new file mode 100644 index 0000000..dc0c86f --- /dev/null +++ b/internal/rotate/aws_test.go @@ -0,0 +1,222 @@ +package rotate + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// awsEmu is a minimal IAM/STS Query-API emulator that ENFORCES access-key validity: +// every request must be SigV4-signed by a key that currently EXISTS (the emulator +// reads the AccessKeyId from the Authorization Credential= field), and +// DeleteAccessKey makes a key stop working. That lets the test prove a real cutover +// — old key rejected (403) after revoke, new key alive — which moto's default mode +// does not enforce. It does not verify the signature math (that is proven by the +// live moto POC); it proves the DRIVER'S create→verify→revoke ordering and that the +// old key is precisely targeted. +type awsEmu struct { + mu sync.Mutex + valid map[string]string // AccessKeyId -> SecretAccessKey (existence == validity) + user string + srv *httptest.Server +} + +func newAWSEmu(t *testing.T, user, seedID, seedSecret string) *awsEmu { + e := &awsEmu{ + valid: map[string]string{seedID: seedSecret}, + user: user, + } + e.srv = httptest.NewServer(http.HandlerFunc(e.handle)) + t.Cleanup(e.srv.Close) + return e +} + +func (e *awsEmu) isValid(id string) bool { + e.mu.Lock() + defer e.mu.Unlock() + _, ok := e.valid[id] + return ok +} + +// credKeyID pulls the AccessKeyId out of "Authorization: AWS4-HMAC-SHA256 +// Credential=////aws4_request, ...". +func credKeyID(r *http.Request) string { + h := r.Header.Get("Authorization") + i := strings.Index(h, "Credential=") + if i < 0 { + return "" + } + rest := h[i+len("Credential="):] + if j := strings.IndexByte(rest, '/'); j >= 0 { + return rest[:j] + } + return "" +} + +func (e *awsEmu) handle(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + action := r.Form.Get("Action") + + // Auth gate: the signing key must currently exist. This is what enforces the + // cutover — a deleted key fails here. + if !e.isValid(credKeyID(r)) { + w.Header().Set("Content-Type", "text/xml") + w.WriteHeader(http.StatusForbidden) + io.WriteString(w, `InvalidClientTokenIdkey revoked`) + return + } + + w.Header().Set("Content-Type", "text/xml") + switch action { + case "CreateAccessKey": + raw := make([]byte, 16) + rand.Read(raw) + newID := "AKIA" + strings.ToUpper(hex.EncodeToString(raw[:8])) + raw2 := make([]byte, 30) + rand.Read(raw2) + newSecret := hex.EncodeToString(raw2) + e.mu.Lock() + e.valid[newID] = newSecret + e.mu.Unlock() + fmt.Fprintf(w, ``+ + `%s%sActive`+ + `%s`+ + ``, e.user, newID, newSecret) + case "DeleteAccessKey": + id := r.Form.Get("AccessKeyId") + e.mu.Lock() + delete(e.valid, id) + e.mu.Unlock() + io.WriteString(w, `1`) + case "GetCallerIdentity": + fmt.Fprintf(w, ``+ + `arn:aws:iam::123456789012:user/%sAIDAEMULATED`+ + `123456789012`, e.user) + default: + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `InvalidAction`) + } +} + +func TestAWSRotateRealCutover(t *testing.T) { + v := vault.New() + defer v.Purge() + ctx := context.Background() + + const user, oldID, oldSecret = "alice", "AKIAOLDKEYEXAMPLE000", "oldSecretAccessKey0123456789abcdef" + emu := newAWSEmu(t, user, oldID, oldSecret) + + oldBlob := awsSecret{keyID: oldID, secret: oldSecret, region: "us-east-1", endpoint: emu.srv.URL}.build() + cred := discover.Credential{ + Source: "aws", + Kind: discover.KindAWSKey, + Identity: user + " / AKIA***000", + Location: "imported/aws/default", + Secret: v.Store([]byte(oldBlob)), + } + + a := &AWS{} + + if !emu.isValid(oldID) { + t.Fatal("seed key should be valid at start") + } + + // 1. Rotate — create a new key (old still valid). + newH, err := a.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + ns, err := parseAWSSecret(v, newH) + if err != nil { + t.Fatalf("parse new secret: %v", err) + } + if ns.keyID == oldID || ns.secret == oldSecret { + t.Fatal("new key equals old — rotation minted nothing") + } + if !emu.isValid(oldID) { + t.Fatal("old key must remain valid before revoke") + } + if !emu.isValid(ns.keyID) { + t.Fatal("new key must be valid after create") + } + + // 2. Verify(new) via the driver (STS GetCallerIdentity with the new key). + if err := a.Verify(ctx, newH, v); err != nil { + t.Fatalf("Verify(new): %v", err) + } + + // 3. RevokeOld — DeleteAccessKey targeting the OLD AccessKeyId. + if err := a.RevokeOld(ctx, cred, v); err != nil { + t.Fatalf("RevokeOld: %v", err) + } + + // Cutover assertions: old dead, new alive. + if emu.isValid(oldID) { + t.Fatal("old key must be invalid after revoke") + } + if err := a.Verify(ctx, cred.Secret, v); err == nil { + t.Fatal("old key must NOT authenticate after revoke") + } + if err := a.Verify(ctx, newH, v); err != nil { + t.Fatalf("new key must still authenticate after revoke: %v", err) + } + + // Leak check: key material must not appear in non-secret credential fields. + for _, f := range []string{cred.Identity, cred.Source, cred.Location, string(cred.Kind)} { + if strings.Contains(f, oldSecret) || strings.Contains(f, ns.secret) { + t.Errorf("secret leaked into non-secret field %q", f) + } + } +} + +func TestAWSSecretRoundTrip(t *testing.T) { + v := vault.New() + defer v.Purge() + // SecretAccessKey commonly contains '/' and '+'; ensure the blob round-trips. + in := awsSecret{keyID: "AKIAEXAMPLE", secret: "ab/cd+ef==Ghi", region: "eu-west-2", endpoint: "http://127.0.0.1:5000"} + h := v.Store([]byte(in.build())) + out, err := parseAWSSecret(v, h) + if err != nil { + t.Fatalf("parse: %v", err) + } + if out != in { + t.Fatalf("round-trip mismatch: %+v != %+v", out, in) + } +} + +func TestSigV4HeadersStable(t *testing.T) { + // A fixed input must yield the canonical AWS SigV4 example-style header shape: + // Credential/SignedHeaders/Signature present and the signing key chain applied. + hdrs, err := sigV4Headers(http.MethodPost, "https://iam.amazonaws.com/", "us-east-1", "iam", + []byte("Action=CreateAccessKey&Version=2010-05-08"), "AKIDEXAMPLE", "secretkey", + time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + auth := hdrs["Authorization"] + for _, want := range []string{ + "AWS4-HMAC-SHA256 ", + "Credential=AKIDEXAMPLE/", + "/us-east-1/iam/aws4_request", + "SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date", + "Signature=", + } { + if !strings.Contains(auth, want) { + t.Errorf("Authorization missing %q; got %q", want, auth) + } + } + if hdrs["X-Amz-Date"] == "" || hdrs["X-Amz-Content-Sha256"] == "" { + t.Error("missing x-amz-date / x-amz-content-sha256") + } +} diff --git a/internal/rotate/dryrun.go b/internal/rotate/dryrun.go new file mode 100644 index 0000000..1d17c91 --- /dev/null +++ b/internal/rotate/dryrun.go @@ -0,0 +1,65 @@ +package rotate + +import ( + "context" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// DryRunResult records, without any secret, what the rotation spine did for one +// credential during a dry run. +type DryRunResult struct { + Credential discover.Credential + Driver string + Rotated bool // a new secret was minted in-vault + Verified bool // the new secret passed Verify + Revoked bool // RevokeOld was invoked (no real effect for the noop driver) + Err error // first error encountered for this credential, if any +} + +// DryRun walks the post-backup rotation spine — Detect → Rotate → Verify → +// RevokeOld — for every credential that driver d claims, WITHOUT storing +// anything in gopass and WITHOUT persisting the minted secret (it is Forgotten +// from the vault immediately after). It exists so the full flow can be exercised +// against the noop driver (docs/ROTATION.md §6 step 1) touching no service. +// +// The MANDATORY backup gate (Snapshot) is the caller's responsibility and must +// already have succeeded; DryRun does not perform it. +func DryRun(ctx context.Context, d Rotator, creds []discover.Credential, v *vault.Vault) []DryRunResult { + var out []DryRunResult + for _, c := range creds { + if !d.Detect(c) { + continue + } + res := DryRunResult{Credential: c, Driver: d.Name()} + + newH, err := d.Rotate(ctx, c, v) + if err != nil { + res.Err = err + out = append(out, res) + continue + } + res.Rotated = true + + // Verify-before-revoke: only proceed to revoke once the new secret checks out. + if err := d.Verify(ctx, newH, v); err != nil { + res.Err = err + v.Forget(newH) + out = append(out, res) + continue + } + res.Verified = true + + // A real run stores the new secret in gopass here; a dry run does not. + if err := d.RevokeOld(ctx, c, v); err != nil { + res.Err = err + } else { + res.Revoked = true + } + + v.Forget(newH) // never persist the minted secret in a dry run + out = append(out, res) + } + return out +} diff --git a/internal/rotate/dryrun_test.go b/internal/rotate/dryrun_test.go new file mode 100644 index 0000000..302899d --- /dev/null +++ b/internal/rotate/dryrun_test.go @@ -0,0 +1,74 @@ +package rotate + +import ( + "context" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// TestDryRunSpine confirms DryRun walks rotate→verify→revoke for every detected +// credential and persists nothing: the minted secret is Forgotten, and the +// original credential is left intact. +func TestDryRunSpine(t *testing.T) { + v := vault.New() + defer v.Purge() + + old := []byte("OLD-VALUE") + oldH := v.Store(append([]byte(nil), old...)) + creds := []discover.Credential{ + {Source: "aws", Identity: "default / AKIA…REDACTED", Secret: oldH}, + {Source: "env", Identity: ".env / API_TOKEN"}, + } + + d := &NoopRotator{} + results := DryRun(context.Background(), d, creds, v) + + if len(results) != 2 { + t.Fatalf("got %d results, want 2", len(results)) + } + for _, r := range results { + if r.Err != nil { + t.Fatalf("%s: unexpected error: %v", r.Credential.Identity, r.Err) + } + if !r.Rotated || !r.Verified || !r.Revoked { + t.Fatalf("%s: spine incomplete: rotated=%t verified=%t revoked=%t", + r.Credential.Identity, r.Rotated, r.Verified, r.Revoked) + } + } + + // Every credential was revoked exactly once, after verify. + if got := len(d.Revoked()); got != 2 { + t.Fatalf("revoked %d, want 2", got) + } + + // The original secret must remain readable — a dry run persists nothing and + // disturbs nothing. + ob, err := v.Open(oldH) + if err != nil { + t.Fatalf("old secret disturbed: %v", err) + } + if string(ob.Bytes()) != string(old) { + t.Fatal("old secret value changed during dry run") + } +} + +// TestDryRunDetectFilter confirms DryRun skips credentials a driver does not +// claim, so a real (selective) driver only touches its own kind. +func TestDryRunDetectFilter(t *testing.T) { + v := vault.New() + defer v.Purge() + + creds := []discover.Credential{{Source: "aws", Identity: "a"}, {Source: "env", Identity: "b"}} + results := DryRun(context.Background(), onlyAWS{&NoopRotator{}}, creds, v) + if len(results) != 1 || results[0].Credential.Source != "aws" { + t.Fatalf("expected only the aws credential, got %+v", results) + } +} + +// onlyAWS is a minimal test driver that claims only aws credentials, reusing the +// noop's rotate/verify/revoke. It embeds a pointer so it carries no copied lock. +type onlyAWS struct{ *NoopRotator } + +func (onlyAWS) Detect(c discover.Credential) bool { return c.Source == "aws" } diff --git a/internal/rotate/execute.go b/internal/rotate/execute.go new file mode 100644 index 0000000..b62679a --- /dev/null +++ b/internal/rotate/execute.go @@ -0,0 +1,108 @@ +package rotate + +import ( + "context" + "fmt" + + "incredigo/internal/discover" + "incredigo/internal/sink" + "incredigo/internal/vault" +) + +// ExecuteResult records, without any secret, what the REAL rotation spine did for +// one credential. +type ExecuteResult struct { + Credential discover.Credential + Driver string + StorePath string // gopass entry the new secret was written to + Rotated bool // a new secret was minted at the provider + Verified bool // the new secret authenticated + Stored bool // the new secret was written to gopass and re-read OK + Revoked bool // RevokeOld was invoked + Err error // first error for this credential, if any +} + +// Execute performs the REAL, destructive rotation spine for every credential a +// registered driver claims, enforcing Hard Rule #2 (verify-new-before-revoke-old): +// +// Rotate → Verify(new) → store new in gopass → re-read + Verify(stored) → RevokeOld +// +// The old credential is revoked ONLY after the new secret authenticates, is +// persisted, and is read back and re-verified. Any failure short-circuits before +// RevokeOld, leaving the old credential intact (the §3 sealed backup is the +// recovery path on top of that). +// +// The MANDATORY backup gate (Snapshot) MUST already have succeeded — Execute does +// not perform it. Each credential's StorePath is taken from c.Location (the exact +// gopass path it was sourced from), so the new secret overwrites the same entry. +func Execute(ctx context.Context, gp *sink.Gopass, creds []discover.Credential, v *vault.Vault) []ExecuteResult { + drivers := Drivers() + var out []ExecuteResult + for _, c := range creds { + var d Rotator + for _, cand := range drivers { + if cand.Detect(c) { + d = cand + break + } + } + if d == nil { + continue // no driver claims it — handled by the manual worklist, not here + } + res := ExecuteResult{Credential: c, Driver: d.Name(), StorePath: c.Location} + + newH, err := d.Rotate(ctx, c, v) + if err != nil { + res.Err = fmt.Errorf("rotate: %w", err) + out = append(out, res) + continue + } + res.Rotated = true + + // Verify the new secret authenticates BEFORE it is stored or the old revoked. + if err := d.Verify(ctx, newH, v); err != nil { + res.Err = fmt.Errorf("verify(new): %w", err) + v.Forget(newH) + out = append(out, res) + continue + } + res.Verified = true + + // Persist the new secret at the SAME gopass path (overwrite). + if err := gp.InsertAt(ctx, v, res.StorePath, newH, true); err != nil { + res.Err = fmt.Errorf("store: %w", err) + v.Forget(newH) + out = append(out, res) + continue + } + v.Forget(newH) + + // Re-read what was stored and prove it still authenticates. This closes the + // gap between "Verify passed on the in-vault value" and "the bytes gopass + // now holds are the working secret" — only then is revoke safe. + rh, err := gp.Show(ctx, v, res.StorePath) + if err != nil { + res.Err = fmt.Errorf("re-read: %w", err) + out = append(out, res) + continue + } + if err := d.Verify(ctx, rh, v); err != nil { + res.Err = fmt.Errorf("verify(stored): %w", err) + v.Forget(rh) + out = append(out, res) + continue + } + v.Forget(rh) + res.Stored = true + + // Only now retire the old credential. + if err := d.RevokeOld(ctx, c, v); err != nil { + res.Err = fmt.Errorf("revoke-old: %w", err) + out = append(out, res) + continue + } + res.Revoked = true + out = append(out, res) + } + return out +} diff --git a/internal/rotate/gitea.go b/internal/rotate/gitea.go new file mode 100644 index 0000000..2abf144 --- /dev/null +++ b/internal/rotate/gitea.go @@ -0,0 +1,256 @@ +package rotate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// Gitea rotates a Gitea personal access token (PAT) via the Gitea API by +// CREATING a new token, verifying it, then DELETING the old one — the canonical +// provider-API rotation pattern (create-new → verify → revoke-old), and the first +// driver whose RevokeOld is a real destructive API call. +// +// The credential's secret is a single self-contained line (so it round-trips +// through the existing single-line gopass sink unchanged): +// +// https://:@[:port]/?name=[&pw=] +// +// - the PAT being rotated (the userinfo password). +// - the Gitea username (userinfo user). +// - scheme+host the API base (http for the loopback sandbox; https in prod). +// - name= the token's Gitea NAME — required to DELETE it (the API deletes +// by name/id, and the token VALUE can't be mapped back to a name). +// incredigo writes the new name on every rotation, so the chain is +// self-sustaining; a bare discovered token with no name degrades to +// the guided worklist for the old-token deletion. +// - pw= OPTIONAL Gitea account password. Gitea historically requires +// BASIC auth (password) to create/delete tokens; if present it is +// used for those management calls. If absent, the driver tries +// token auth (works on Gitea versions that allow a scoped token to +// manage tokens). Verify always uses the token itself. +// +// SECURITY: the token (and optional mgmt password) live only in the vault and the +// query string of the in-vault secret; they never reach Identity/Meta/logs. HTTP +// bodies carrying the new token are drained into the vault, not stringified beyond +// the unavoidable JSON decode. +type Gitea 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(&Gitea{}) } + +// Name identifies the driver in plans and audit records. +func (g *Gitea) Name() string { return "gitea" } + +// Detect claims credentials tagged Source == "gitea". +func (g *Gitea) Detect(c discover.Credential) bool { return c.Source == "gitea" } + +func (g *Gitea) client() *http.Client { + if g.HTTPClient != nil { + return g.HTTPClient + } + return &http.Client{Timeout: 15 * time.Second} +} + +// giteaSecret is the parsed credential blob. None of its fields are ever logged. +type giteaSecret struct { + base string // scheme://host[:port] + user string + token string + name string // token name (for DELETE) + pw string // optional mgmt password (basic auth) +} + +// parseGiteaSecret reads the single-line blob from the vault and decomposes it. +func parseGiteaSecret(v *vault.Vault, h *vault.Handle) (giteaSecret, error) { + buf, err := v.Open(h) + if err != nil { + return giteaSecret{}, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return giteaSecret{}, fmt.Errorf("gitea: parse secret url: %w", err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return giteaSecret{}, fmt.Errorf("gitea: unexpected scheme %q", u.Scheme) + } + if u.User == nil { + return giteaSecret{}, fmt.Errorf("gitea: secret has no user:token") + } + tok, _ := u.User.Password() + if tok == "" { + return giteaSecret{}, fmt.Errorf("gitea: secret has no token") + } + q := u.Query() + return giteaSecret{ + base: u.Scheme + "://" + u.Host, + user: u.User.Username(), + token: tok, + name: q.Get("name"), + pw: q.Get("pw"), + }, nil +} + +// build re-encodes a giteaSecret into the single-line blob form. +func (s giteaSecret) build() string { + u := &url.URL{ + Scheme: schemeOf(s.base), + Host: hostOf(s.base), + User: url.UserPassword(s.user, s.token), + Path: "/", + } + q := url.Values{} + q.Set("name", s.name) + if s.pw != "" { + q.Set("pw", s.pw) + } + u.RawQuery = q.Encode() + return u.String() +} + +func schemeOf(base string) string { + if i := strings.Index(base, "://"); i >= 0 { + return base[:i] + } + return "https" +} + +func hostOf(base string) string { + if i := strings.Index(base, "://"); i >= 0 { + return base[i+3:] + } + return base +} + +// Rotate creates a NEW token at the Gitea API (authenticating with the old token +// or, if a mgmt password is present, basic auth), and returns a vault handle to a +// rebuilt blob carrying the new token + new name. It does not revoke the old token. +func (g *Gitea) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + s, err := parseGiteaSecret(v, c.Secret) + if err != nil { + return nil, err + } + newName := fmt.Sprintf("incredigo-rotated-%d", time.Now().Unix()) + body, _ := json.Marshal(map[string]any{ + "name": newName, + "scopes": []string{"write:user", "read:user"}, // enough to manage tokens on Gitea ≥1.19; ignored by older + }) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + s.base+"/api/v1/users/"+url.PathEscape(s.user)+"/tokens", bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + g.authManage(req, s) + + resp, err := g.client().Do(req) + if err != nil { + return nil, fmt.Errorf("gitea: create token: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gitea: create token: unexpected status %s", resp.Status) + } + var created struct { + Name string `json:"name"` + SHA1 string `json:"sha1"` + } + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + return nil, fmt.Errorf("gitea: decode created token: %w", err) + } + if created.SHA1 == "" { + return nil, fmt.Errorf("gitea: create token: empty token in response") + } + + ns := s // carry base/user/pw forward + ns.token = created.SHA1 + ns.name = created.Name + if ns.name == "" { + ns.name = newName + } + return v.Store([]byte(ns.build())), nil +} + +// Verify proves the newly minted token authenticates by calling GET /api/v1/user +// with it and checking the returned login matches. +func (g *Gitea) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + s, err := parseGiteaSecret(v, newSecret) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/api/v1/user", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+s.token) + resp, err := g.client().Do(req) + if err != nil { + return fmt.Errorf("gitea verify: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("gitea verify: GET /user status %s", resp.Status) + } + var who struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&who); err != nil { + return fmt.Errorf("gitea verify: decode user: %w", err) + } + if s.user != "" && who.Login != "" && !strings.EqualFold(who.Login, s.user) { + return fmt.Errorf("gitea verify: token authenticates as %q, expected %q", who.Login, s.user) + } + return nil +} + +// RevokeOld deletes the OLD token by name. The old token is still valid at this +// point (revoke runs only after the new token is stored+verified), so it is used +// to authenticate its own deletion unless a mgmt password is configured. +func (g *Gitea) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + s, err := parseGiteaSecret(v, c.Secret) + if err != nil { + return err + } + if s.name == "" { + return fmt.Errorf("gitea revoke: old token has no recorded name — delete it manually (guided)") + } + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, + s.base+"/api/v1/users/"+url.PathEscape(s.user)+"/tokens/"+url.PathEscape(s.name), nil) + if err != nil { + return err + } + g.authManage(req, s) + resp, err := g.client().Do(req) + if err != nil { + return fmt.Errorf("gitea 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("gitea revoke: delete token status %s", resp.Status) + } + return nil +} + +// authManage sets auth for token-management calls (create/delete): basic auth with +// the mgmt password if present (Gitea's historical requirement), else token auth. +func (g *Gitea) authManage(req *http.Request, s giteaSecret) { + if s.pw != "" { + req.SetBasicAuth(s.user, s.pw) + return + } + req.Header.Set("Authorization", "token "+s.token) +} diff --git a/internal/rotate/gitea_test.go b/internal/rotate/gitea_test.go new file mode 100644 index 0000000..557059f --- /dev/null +++ b/internal/rotate/gitea_test.go @@ -0,0 +1,198 @@ +package rotate + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// giteaEmu is a minimal Gitea API emulator that ENFORCES token validity: a token +// authenticates only while it exists, and DELETE makes it stop working — so the +// test can prove a real cutover (old token dead, new token alive). +type giteaEmu struct { + mu sync.Mutex + byName map[string]string // name -> token value + byValue map[string]string // token value -> name + user string + srv *httptest.Server + createOK func(r *http.Request) bool // auth gate for create/delete +} + +func newGiteaEmu(t *testing.T, user, seedName, seedValue string) *giteaEmu { + e := &giteaEmu{ + byName: map[string]string{seedName: seedValue}, + byValue: map[string]string{seedValue: seedName}, + user: user, + } + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/user", e.handleUser) + mux.HandleFunc("/api/v1/users/", e.handleTokens) + e.srv = httptest.NewServer(mux) + t.Cleanup(e.srv.Close) + return e +} + +func (e *giteaEmu) valid(token string) bool { + e.mu.Lock() + defer e.mu.Unlock() + _, ok := e.byValue[token] + return ok +} + +// presentedToken returns the bearer token from an "Authorization: token " header. +func presentedToken(r *http.Request) string { + h := r.Header.Get("Authorization") + if strings.HasPrefix(h, "token ") { + return strings.TrimPrefix(h, "token ") + } + return "" +} + +func (e *giteaEmu) handleUser(w http.ResponseWriter, r *http.Request) { + if !e.valid(presentedToken(r)) { + w.WriteHeader(http.StatusUnauthorized) + return + } + json.NewEncoder(w).Encode(map[string]any{"login": e.user, "id": 1}) +} + +func (e *giteaEmu) handleTokens(w http.ResponseWriter, r *http.Request) { + // Authorize management calls: a valid token (token auth) or any basic auth. + authed := e.valid(presentedToken(r)) + if u, _, ok := r.BasicAuth(); ok && u == e.user { + authed = true + } + if e.createOK != nil { + authed = e.createOK(r) + } + if !authed { + w.WriteHeader(http.StatusForbidden) + return + } + + switch r.Method { + case http.MethodPost: + var body struct { + Name string `json:"name"` + } + json.NewDecoder(r.Body).Decode(&body) + raw := make([]byte, 20) + rand.Read(raw) + val := hex.EncodeToString(raw) + e.mu.Lock() + e.byName[body.Name] = val + e.byValue[val] = body.Name + e.mu.Unlock() + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{"id": 2, "name": body.Name, "sha1": val}) + case http.MethodDelete: + // path: /api/v1/users/{user}/tokens/{name} + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + name := parts[len(parts)-1] + e.mu.Lock() + if v, ok := e.byName[name]; ok { + delete(e.byValue, v) + delete(e.byName, name) + } + e.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func TestGiteaRotateRealCutover(t *testing.T) { + v := vault.New() + defer v.Purge() + ctx := context.Background() + + const user, seedName, oldTok = "alice", "seed", "0123456789abcdef0123456789abcdef01234567" + emu := newGiteaEmu(t, user, seedName, oldTok) + + oldBlob := emu.srv.URL + "/?name=" + seedName // becomes http://host/?name=seed with userinfo below + // Build the blob with userinfo: http://alice:oldtok@host/?name=seed + oldBlob = strings.Replace(emu.srv.URL, "http://", "http://"+user+":"+oldTok+"@", 1) + "/?name=" + seedName + + cred := discover.Credential{ + Source: "gitea", + Kind: discover.KindToken, + Identity: user + " @ gitea", + Location: "imported/gitea/alice", + Secret: v.Store([]byte(oldBlob)), + } + + g := &Gitea{} + + // Baseline: the seeded token authenticates. + if !emu.valid(oldTok) { + t.Fatal("seed token should be valid at start") + } + + // 1. Rotate — create a new token (old still valid). + newH, err := g.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + ns, err := parseGiteaSecret(v, newH) + if err != nil { + t.Fatalf("parse new secret: %v", err) + } + if ns.token == oldTok { + t.Fatal("new token equals old — rotation minted nothing") + } + if !emu.valid(oldTok) { + t.Fatal("old token must remain valid before revoke") + } + if !emu.valid(ns.token) { + t.Fatal("new token must be valid after create") + } + + // 2. Verify(new) via the driver. + if err := g.Verify(ctx, newH, v); err != nil { + t.Fatalf("Verify(new): %v", err) + } + + // 3. RevokeOld — delete the old token by name. + if err := g.RevokeOld(ctx, cred, v); err != nil { + t.Fatalf("RevokeOld: %v", err) + } + + // Cutover assertions: old dead, new alive. + if emu.valid(oldTok) { + t.Fatal("old token must be invalid after revoke") + } + if err := g.Verify(ctx, newH, v); err != nil { + t.Fatalf("new token must still authenticate after revoke: %v", err) + } + + // Leak check: token values must not appear in non-secret credential fields. + for _, f := range []string{cred.Identity, cred.Source, cred.Location, string(cred.Kind)} { + if strings.Contains(f, oldTok) || strings.Contains(f, ns.token) { + t.Errorf("token leaked into non-secret field %q", f) + } + } +} + +func TestGiteaRevokeRequiresName(t *testing.T) { + v := vault.New() + defer v.Purge() + g := &Gitea{} + // Blob with no name= query → RevokeOld must refuse (guided), not silently pass. + cred := discover.Credential{ + Source: "gitea", + Secret: v.Store([]byte("https://bob:tok123@gitea.example.com/")), + } + err := g.RevokeOld(context.Background(), cred, v) + if err == nil || !strings.Contains(err.Error(), "no recorded name") { + t.Fatalf("expected revoke-without-name error, got %v", err) + } +} diff --git a/internal/rotate/mullvad.go b/internal/rotate/mullvad.go new file mode 100644 index 0000000..60a91c8 --- /dev/null +++ b/internal/rotate/mullvad.go @@ -0,0 +1,290 @@ +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://@/?device=&privkey= +// +// - 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. +// - 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 +} diff --git a/internal/rotate/mullvad_test.go b/internal/rotate/mullvad_test.go new file mode 100644 index 0000000..2c939ca --- /dev/null +++ b/internal/rotate/mullvad_test.go @@ -0,0 +1,180 @@ +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-. + 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") + } +} diff --git a/internal/rotate/mysql.go b/internal/rotate/mysql.go new file mode 100644 index 0000000..6ea3b7a --- /dev/null +++ b/internal/rotate/mysql.go @@ -0,0 +1,187 @@ +package rotate + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "strings" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// MySQL rotates a MySQL/MariaDB account password in place, the same in-place DB +// pattern as Postgres. The credential's secret is the full DSN +// ("mysql://user:pw@host:port/db"); rotation mints a new password and runs +// +// ALTER USER CURRENT_USER() IDENTIFIED BY '' +// +// over the OLD connection. CURRENT_USER() targets the *currently authenticated* +// account, so the driver never needs to know the user's 'name'@'host' grant tuple, +// and it is the form accepted by BOTH MySQL and MariaDB (MariaDB rejects the +// MySQL-only USER() spelling in ALTER USER). The ALTER is +// itself the cutover (the old password stops working immediately), so RevokeOld is a +// no-op and the §3 sealed backup is the recovery path — identical reasoning to +// Postgres (see docs/ROTATION.md §17). +// +// SECURITY: the password reaches the mysql client only via the MYSQL_PWD +// *environment* variable (never argv, never disk), and the SQL — which contains the +// NEW password — is fed on stdin so it never appears in the process argument list. +// The new password is hex ([0-9a-f]) so the single-quoted SQL literal cannot break +// out. Captured output is scrubbed of the new password before any error surfaces. +type MySQL struct { + Bin string // mysql client binary; defaults to "mysql" +} + +// init registers the driver. Registration only makes it available; changing a +// credential still requires `rotate --execute` AND INCREDIGO_ALLOW_EXECUTE=1. +func init() { Register(&MySQL{}) } + +// Name identifies the driver in plans and audit records. +func (m *MySQL) Name() string { return "mysql" } + +// Detect claims credentials a mysql scanner/seed emitted (Source == "mysql"). +func (m *MySQL) Detect(c discover.Credential) bool { return c.Source == "mysql" } + +func (m *MySQL) bin() string { + if m.Bin != "" { + return m.Bin + } + return "mysql" +} + +// Rotate mints a new password, applies it to the currently-authenticated account +// over the existing (old) connection, and returns a vault handle to the rebuilt DSN. +// +// It tries, in order, the statements a user can run to change *their own* password, +// because no single statement works unprivileged across both engines and versions: +// +// 1. ALTER USER CURRENT_USER() IDENTIFIED BY '…' — MySQL 5.7/8.0 self-service (no +// special privilege). MariaDB rejects this for a non-admin (it demands the global +// CREATE USER privilege even for your own account), so we fall through. +// 2. SET PASSWORD = PASSWORD('…') — MariaDB self-service (no priv); +// also MySQL 5.7. Removed in MySQL 8.0, so we fall through there. +// 3. SET PASSWORD = '…' — MySQL 8.0 / MariaDB ≥10.4. +// +// The first to succeed wins. This matters for the target persona: a vibe coder's DB +// user is usually scoped to one schema (GRANT ALL ON app.*) and CANNOT ALTER USER on +// MariaDB, but can always change its own password via SET PASSWORD. +func (m *MySQL) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + u, err := openMySQLDSN(v, c.Secret) + if err != nil { + return nil, err + } + if u.User.Username() == "" { + return nil, errors.New("mysql: dsn has no user") + } + oldPw, _ := u.User.Password() + + newPw, err := genHex(24) + if err != nil { + return nil, err + } + // New password is hex ([0-9a-f]) so the single-quoted literals below cannot break + // out of the statement. + stmts := []string{ + fmt.Sprintf("ALTER USER CURRENT_USER() IDENTIFIED BY '%s';", newPw), + fmt.Sprintf("SET PASSWORD = PASSWORD('%s');", newPw), + fmt.Sprintf("SET PASSWORD = '%s';", newPw), + } + var lastErr error + changed := false + for _, sql := range stmts { + if _, err := m.mysql(ctx, u, oldPw, sql, newPw); err == nil { + changed = true + break + } else { + lastErr = err + } + } + if !changed { + return nil, fmt.Errorf("mysql: change password: %w", lastErr) + } + + nu := *u + nu.User = url.UserPassword(u.User.Username(), newPw) + return v.Store([]byte(nu.String())), nil +} + +// Verify proves the freshly minted DSN authenticates by issuing SELECT 1 with the +// new password. +func (m *MySQL) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + u, err := openMySQLDSN(v, newSecret) + if err != nil { + return err + } + pw, _ := u.User.Password() + out, err := m.mysql(ctx, u, pw, "SELECT 1;", pw) + if err != nil { + return fmt.Errorf("mysql verify: %w", err) + } + if !strings.Contains(out, "1") { + return fmt.Errorf("mysql verify: unexpected result") + } + return nil +} + +// RevokeOld is a no-op for an in-place engine: the ALTER in Rotate already +// invalidated the previous password. Defined to satisfy the safety-spine ordering. +func (m *MySQL) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + return nil +} + +// mysql runs one SQL statement against the DSN's host/db as the DSN's user, +// authenticating via MYSQL_PWD and feeding sql on stdin (keeping any secret out of +// argv). redact, if non-empty, is scrubbed from captured output before it can reach +// an error string. +func (m *MySQL) mysql(ctx context.Context, u *url.URL, password, sql, redact string) (string, error) { + host := u.Hostname() + port := u.Port() + if port == "" { + port = "3306" + } + args := []string{ + "--batch", "--skip-column-names", + "-h", host, "-P", port, "-u", u.User.Username(), + } + if db := strings.TrimPrefix(u.Path, "/"); db != "" { + args = append(args, "-D", db) + } + cmd := exec.CommandContext(ctx, m.bin(), args...) + cmd.Env = append(os.Environ(), "MYSQL_PWD="+password) + cmd.Stdin = strings.NewReader(sql + "\n") + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + err := cmd.Run() + so, se := out.String(), errb.String() + if redact != "" { + so = strings.ReplaceAll(so, redact, "***") + se = strings.ReplaceAll(se, redact, "***") + } + if err != nil { + return so, fmt.Errorf("%v: %s", err, strings.TrimSpace(se)) + } + return so, nil +} + +// openMySQLDSN reads a DSN secret from the vault and parses it, accepting the +// mysql:// and mariadb:// schemes. The DSN string is transient and never persisted. +func openMySQLDSN(v *vault.Vault, h *vault.Handle) (*url.URL, error) { + buf, err := v.Open(h) + if err != nil { + return nil, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return nil, fmt.Errorf("mysql: parse dsn: %w", err) + } + if u.Scheme != "mysql" && u.Scheme != "mariadb" { + return nil, fmt.Errorf("mysql: unexpected dsn scheme %q", u.Scheme) + } + return u, nil +} diff --git a/internal/rotate/mysql_test.go b/internal/rotate/mysql_test.go new file mode 100644 index 0000000..104380f --- /dev/null +++ b/internal/rotate/mysql_test.go @@ -0,0 +1,99 @@ +package rotate + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// writeFakeMysql writes a stub `mysql` that emulates password auth against a state +// file holding the account's CURRENT password. ALTER USER updates the state (only if +// MYSQL_PWD matched the current password); SELECT 1 succeeds only when MYSQL_PWD +// matches. This lets a test prove a real cutover: after Rotate the OLD password must +// stop authenticating. +func writeFakeMysql(t *testing.T, statePath string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "mysql") + script := `#!/usr/bin/env bash +set -euo pipefail +sql="$(cat)" +cur="$(cat "` + statePath + `" 2>/dev/null || true)" +case "$sql" in + *"ALTER USER"*) + if [ "${MYSQL_PWD:-}" != "$cur" ]; then echo "ERROR 1045 (28000): Access denied" >&2; exit 1; fi + np="$(printf '%s' "$sql" | sed -E "s/.*IDENTIFIED BY '([^']*)'.*/\1/")" + printf '%s' "$np" > "` + statePath + `" + exit 0 ;; + *"SELECT 1"*) + if [ "${MYSQL_PWD:-}" != "$cur" ]; then echo "ERROR 1045 (28000): Access denied" >&2; exit 1; fi + echo "1"; exit 0 ;; + *) echo "unrecognized" >&2; exit 1 ;; +esac +` + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestMySQLDetect(t *testing.T) { + m := &MySQL{} + if !m.Detect(discover.Credential{Source: "mysql"}) { + t.Error("should detect Source=mysql") + } + if m.Detect(discover.Credential{Source: "postgres"}) { + t.Error("should not detect Source=postgres") + } +} + +func TestMySQLRotateRealCutover(t *testing.T) { + state := filepath.Join(t.TempDir(), "pw") + if err := os.WriteFile(state, []byte("oldpw"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("MYSQL_PWD", "") // ensure the driver, not the env, supplies it + m := &MySQL{Bin: writeFakeMysql(t, state)} + + v := vault.New() + defer v.Purge() + oldDSN := "mysql://labapp:oldpw@127.0.0.1:3306/labdb" + cred := discover.Credential{Source: "mysql", Identity: "labapp@labdb", Secret: v.Store([]byte(oldDSN))} + ctx := context.Background() + + newH, err := m.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + if cur, _ := os.ReadFile(state); string(cur) == "oldpw" { + t.Fatal("password not changed by ALTER USER") + } + + if err := m.Verify(ctx, newH, v); err != nil { + t.Errorf("Verify(new) should pass: %v", err) + } + if err := m.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(), "oldpw") { + t.Error("error leaked the old password") + } + + if err := m.RevokeOld(ctx, cred, v); err != nil { + t.Errorf("RevokeOld (no-op) returned: %v", err) + } +} + +func TestMySQLRejectsNonMySQLDSN(t *testing.T) { + m := &MySQL{Bin: "/bin/false"} + v := vault.New() + defer v.Purge() + cred := discover.Credential{Source: "mysql", Secret: v.Store([]byte("postgres://u:p@h/db"))} + if _, err := m.Rotate(context.Background(), cred, v); err == nil { + t.Error("Rotate should reject a non-mysql scheme") + } +} diff --git a/internal/rotate/noop.go b/internal/rotate/noop.go new file mode 100644 index 0000000..cd4afeb --- /dev/null +++ b/internal/rotate/noop.go @@ -0,0 +1,80 @@ +package rotate + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "sync" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// NoopRotator is a dry-run Rotator that exercises the full rotation flow — +// Detect → Rotate → Verify → RevokeOld — WITHOUT contacting any service or +// changing any real credential. It mints a random in-vault "new" secret so the +// safety spine (backup → rotate → verify → store → revoke) can be tested +// end-to-end (docs/ROTATION.md §6 build-order step 1). +// +// It is intentionally NOT registered in the global registry: the production +// `rotate` command must still report zero drivers and refuse --execute (see +// cmd/incredigo and CLAUDE.md hard rules). Wire it up explicitly in tests, or +// later behind an opt-in dry-run flag. +type NoopRotator struct { + mu sync.Mutex + revoked []string // identities passed to RevokeOld, for assertions +} + +// Name identifies the driver in plans and audit records. +func (r *NoopRotator) Name() string { return "noop" } + +// Detect matches every credential — the dry run applies to anything. +func (r *NoopRotator) Detect(discover.Credential) bool { return true } + +// Rotate mints a fresh random secret in the vault and returns its handle. It +// contacts no provider and leaves the old credential untouched, honoring the +// Rotator contract (mint new, never revoke here). +func (r *NoopRotator) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return nil, err + } + enc := make([]byte, hex.EncodedLen(len(raw))) + hex.Encode(enc, raw) + for i := range raw { // wipe the random source; Store wipes enc itself + raw[i] = 0 + } + return v.Store(enc), nil +} + +// Verify confirms the freshly minted secret is in custody and non-empty. A real +// driver would authenticate it against the service; the noop only checks that +// the new value survived rotation, so verify-before-revoke ordering is testable. +func (r *NoopRotator) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + buf, err := v.Open(newSecret) + if err != nil { + return err + } + if buf.Size() == 0 { + return errors.New("noop verify: new secret is empty") + } + return nil +} + +// RevokeOld retires nothing — there is no real provider — but records the +// identity so a test can assert revoke ran only after verify. Mirrors the +// safety-spine ordering without any destructive effect. +func (r *NoopRotator) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + r.mu.Lock() + defer r.mu.Unlock() + r.revoked = append(r.revoked, c.Identity) + return nil +} + +// Revoked returns a copy of the identities whose RevokeOld was invoked. +func (r *NoopRotator) Revoked() []string { + r.mu.Lock() + defer r.mu.Unlock() + return append([]string(nil), r.revoked...) +} diff --git a/internal/rotate/noop_test.go b/internal/rotate/noop_test.go new file mode 100644 index 0000000..333cdcf --- /dev/null +++ b/internal/rotate/noop_test.go @@ -0,0 +1,117 @@ +package rotate + +import ( + "bytes" + "context" + "path/filepath" + "testing" + + "github.com/awnumar/memguard" + + "incredigo/internal/discover" + "incredigo/internal/sink" + "incredigo/internal/vault" +) + +// TestNoopFullFlow walks the entire safety spine end-to-end with the dry-run +// driver: mandatory backup → Detect → Rotate(mint new) → Verify → store → +// RevokeOld. It asserts the spine's invariants without touching any service: +// the old secret is never disturbed, a distinct new secret is minted, and +// revoke runs only after verify succeeds. +func TestNoopFullFlow(t *testing.T) { + v := vault.New() + defer v.Purge() + + old := []byte("AKIA-OLD-SECRET-1") + oldHandle := v.Store(append([]byte(nil), old...)) + creds := []discover.Credential{ + {Source: "aws", Kind: discover.KindAWSKey, Identity: "default / AKIA…REDACTED", Secret: oldHandle}, + {Source: "env", Kind: discover.KindToken, Identity: "API_TOKEN"}, + } + + // 1. MANDATORY backup gate must pass before anything rotates. + gp := fakeGopass(t) + pass := memguard.NewBufferFromBytes([]byte("flow-pass")) + defer pass.Destroy() + out := filepath.Join(t.TempDir(), "flow.age") + if _, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out); err != nil { + t.Fatalf("backup gate must succeed before rotation: %v", err) + } + + ctx := context.Background() + d := &NoopRotator{} + + for _, c := range creds { + if !d.Detect(c) { + t.Fatalf("noop must Detect %s", c.Identity) + } + + // 2. Rotate mints a NEW secret and must not revoke anything yet. + revokedBefore := len(d.Revoked()) + newHandle, err := d.Rotate(ctx, c, v) + if err != nil { + t.Fatalf("Rotate %s: %v", c.Identity, err) + } + if len(d.Revoked()) != revokedBefore { + t.Fatalf("Rotate must not revoke; revoked=%v", d.Revoked()) + } + + // New secret is real and distinct from the old value. + nb, err := v.Open(newHandle) + if err != nil { + t.Fatalf("open new secret: %v", err) + } + if nb.Size() == 0 { + t.Fatalf("new secret for %s is empty", c.Identity) + } + if bytes.Equal(nb.Bytes(), old) { + t.Fatalf("new secret equals old for %s — rotation minted nothing", c.Identity) + } + + // 3. Verify before revoke. + if err := d.Verify(ctx, newHandle, v); err != nil { + t.Fatalf("Verify %s: %v", c.Identity, err) + } + + // 4. Only now retire the old credential. + if err := d.RevokeOld(ctx, c, v); err != nil { + t.Fatalf("RevokeOld %s: %v", c.Identity, err) + } + } + + // The old secret must remain readable — the noop never touches a real value. + ob, err := v.Open(oldHandle) + if err != nil { + t.Fatalf("old secret was disturbed: %v", err) + } + if !bytes.Equal(ob.Bytes(), old) { + t.Fatal("old secret value changed — dry run must leave originals intact") + } + + if got, want := d.Revoked(), []string{"default / AKIA…REDACTED", "API_TOKEN"}; !equal(got, want) { + t.Fatalf("revoked = %v, want %v", got, want) + } +} + +// TestNoopNotRegistered guards the hard rule: the dry-run driver must not leak +// into the global registry, so the production `rotate` command still sees zero +// drivers and PlanAll reports "(none)". +func TestNoopNotRegistered(t *testing.T) { + for _, d := range Drivers() { + if d.Name() == "noop" { + t.Fatal("NoopRotator must NOT auto-register — production rotate must report zero drivers") + } + } +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/rotate/openwrt.go b/internal/rotate/openwrt.go new file mode 100644 index 0000000..c5f1dbe --- /dev/null +++ b/internal/rotate/openwrt.go @@ -0,0 +1,214 @@ +package rotate + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" + + "golang.org/x/crypto/ssh" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// OpenWrt rotates an OpenWrt router's admin password over SSH — the in-place +// password pattern (the same family as Postgres/MySQL/Redis). On OpenWrt the root +// account password is the single admin secret: it gates SSH (dropbear) AND the LuCI +// web UI (LuCI shells out to `passwd`). The credential's secret is a single +// self-contained line (so it round-trips through the single-line gopass sink): +// +// openwrt://:@[:port]/ +// +// Rotation dials the router with the OLD password, runs `passwd` (feeding the NEW +// password on the session's stdin so it never appears in argv), and returns a +// rebuilt blob with the new password. Setting the password IS the cutover — the old +// password stops authenticating immediately — so RevokeOld is a no-op and the §3 +// sealed backup is the recovery path (lockout risk makes the backup gate essential). +// +// SECURITY (Hard Rule #3 — no plaintext secrets on disk/argv, ever): the new +// password travels only inside the SSH-encrypted stdin stream to the remote `passwd`; +// the command line is just "passwd". The new password is hex ([0-9a-f]) so it needs +// no quoting and cannot inject extra input lines. Captured stderr is scrubbed of the +// new password before any error surfaces. +type OpenWrt struct { + // HostKey defaults to InsecureIgnoreHostKey: we rotate routers the operator owns + // and reaches on the LAN; the trust boundary here is the admin password, not host + // identity. Host-key pinning is a separate concern. + HostKey ssh.HostKeyCallback + // Dial is injectable so tests can point at an in-process SSH server. Defaults to + // ssh.Dial. + Dial func(network, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) +} + +// init self-registers the driver. Availability alone changes nothing; the +// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies. +func init() { Register(&OpenWrt{}) } + +// Name identifies the driver in plans and audit records. +func (o *OpenWrt) Name() string { return "openwrt" } + +// Detect claims credentials tagged Source == "openwrt". +func (o *OpenWrt) Detect(c discover.Credential) bool { return c.Source == "openwrt" } + +func (o *OpenWrt) hostKey() ssh.HostKeyCallback { + if o.HostKey != nil { + return o.HostKey + } + return ssh.InsecureIgnoreHostKey() +} + +func (o *OpenWrt) dialer() func(string, string, *ssh.ClientConfig) (*ssh.Client, error) { + if o.Dial != nil { + return o.Dial + } + return ssh.Dial +} + +// owSecret is the parsed credential blob. None of its fields are ever logged. +type owSecret struct { + user, password, host, port string +} + +func parseOWSecret(v *vault.Vault, h *vault.Handle) (owSecret, error) { + buf, err := v.Open(h) + if err != nil { + return owSecret{}, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return owSecret{}, fmt.Errorf("openwrt: parse secret url: %w", err) + } + if u.Scheme != "openwrt" && u.Scheme != "ssh" { + return owSecret{}, fmt.Errorf("openwrt: unexpected scheme %q", u.Scheme) + } + if u.User == nil || u.User.Username() == "" { + return owSecret{}, errors.New("openwrt: secret has no user") + } + pw, ok := u.User.Password() + if !ok || pw == "" { + return owSecret{}, errors.New("openwrt: secret has no password") + } + port := u.Port() + if port == "" { + port = "22" + } + return owSecret{user: u.User.Username(), password: pw, host: u.Hostname(), port: port}, nil +} + +func (s owSecret) addr() string { return net.JoinHostPort(s.host, s.port) } + +func (s owSecret) build() string { + u := &url.URL{ + Scheme: "openwrt", + Host: net.JoinHostPort(s.host, s.port), + User: url.UserPassword(s.user, s.password), + Path: "/", + } + return u.String() +} + +// Rotate sets a new admin password over the OLD SSH connection and returns a vault +// handle to the rebuilt blob. It does not revoke anything — the passwd change is the +// cutover. +func (o *OpenWrt) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + s, err := parseOWSecret(v, c.Secret) + if err != nil { + return nil, err + } + newPw, err := genHex(18) + if err != nil { + return nil, err + } + if err := o.setPassword(ctx, s, newPw); err != nil { + return nil, fmt.Errorf("openwrt: set password: %w", err) + } + + ns := s + ns.password = newPw + return v.Store([]byte(ns.build())), nil +} + +// Verify proves the new password authenticates by opening an SSH session with it and +// running a trivial command. +func (o *OpenWrt) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + s, err := parseOWSecret(v, newSecret) + if err != nil { + return err + } + out, err := o.run(ctx, s, "echo incredigo-ok") + if err != nil { + return fmt.Errorf("openwrt verify: %w", err) + } + if !strings.Contains(out, "incredigo-ok") { + return errors.New("openwrt verify: unexpected result") + } + return nil +} + +// RevokeOld is a no-op for an in-place engine: setting the password in Rotate already +// invalidated the previous one. Defined to satisfy the safety-spine ordering. +func (o *OpenWrt) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + return nil +} + +// dialSSH opens an SSH client to the router authenticating with the secret's +// password. +func (o *OpenWrt) dialSSH(s owSecret) (*ssh.Client, error) { + cfg := &ssh.ClientConfig{ + User: s.user, + Auth: []ssh.AuthMethod{ssh.Password(s.password)}, + HostKeyCallback: o.hostKey(), + Timeout: 10 * time.Second, + } + return o.dialer()("tcp", s.addr(), cfg) +} + +// run executes one command over a fresh SSH session and returns its stdout. +func (o *OpenWrt) run(ctx context.Context, s owSecret, cmd string) (string, error) { + cli, err := o.dialSSH(s) + if err != nil { + return "", fmt.Errorf("ssh dial %s: %w", s.addr(), err) + } + defer cli.Close() + sess, err := cli.NewSession() + if err != nil { + return "", err + } + defer sess.Close() + var out, errb bytes.Buffer + sess.Stdout = &out + sess.Stderr = &errb + if err := sess.Run(cmd); err != nil { + return out.String(), fmt.Errorf("%v: %s", err, strings.TrimSpace(errb.String())) + } + return out.String(), nil +} + +// setPassword runs `passwd` for the connected (root) account, feeding the new +// password twice on stdin (BusyBox passwd reads new+retype from a non-tty stdin). +// The password reaches the router only inside the SSH-encrypted stdin stream. +func (o *OpenWrt) setPassword(ctx context.Context, s owSecret, newPw string) error { + cli, err := o.dialSSH(s) + if err != nil { + return fmt.Errorf("ssh dial %s: %w", s.addr(), err) + } + defer cli.Close() + sess, err := cli.NewSession() + if err != nil { + return err + } + defer sess.Close() + sess.Stdin = strings.NewReader(newPw + "\n" + newPw + "\n") + var errb bytes.Buffer + sess.Stderr = &errb + if err := sess.Run("passwd"); err != nil { + se := strings.ReplaceAll(errb.String(), newPw, "***") + return fmt.Errorf("%v: %s", err, strings.TrimSpace(se)) + } + return nil +} diff --git a/internal/rotate/openwrt_test.go b/internal/rotate/openwrt_test.go new file mode 100644 index 0000000..f43bd3d --- /dev/null +++ b/internal/rotate/openwrt_test.go @@ -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") + } +} diff --git a/internal/rotate/postgres.go b/internal/rotate/postgres.go new file mode 100644 index 0000000..3ccafc6 --- /dev/null +++ b/internal/rotate/postgres.go @@ -0,0 +1,176 @@ +package rotate + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "strings" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// Postgres rotates a PostgreSQL role password in place. The credential's secret +// is the full libpq DSN ("postgres://user:pw@host:port/db"); rotation mints a new +// password, runs ALTER USER over the OLD connection, and stores a rebuilt DSN. +// +// In-place engines have no separate "old credential" to retire: the ALTER itself +// invalidates the old password, so RevokeOld is a no-op and Verify runs +// immediately after the change (the §3 sealed backup is the recovery path). See +// docs/ROTATION.md §17. +// +// SECURITY: rotation is the one boundary where a secret must leave the vault to +// authenticate — here it goes to psql via the PGPASSWORD *environment* variable +// (never disk, never argv, never logged), and the SQL is fed on stdin so the new +// password never appears in the process argument list (visible via ps/proc). The +// transient DSN/password strings below are the unavoidable cost of talking to the +// driver; they are not written anywhere and stderr is redacted before surfacing. +type Postgres struct { + Bin string // psql binary; defaults to "psql" +} + +// init registers the postgres driver (idiomatic database/sql-style self-register). +// Registration only makes the driver *available*; actually changing a credential +// still requires `rotate --execute` AND INCREDIGO_ALLOW_EXECUTE=1 (see main.go). +func init() { Register(&Postgres{}) } + +// Name identifies the driver in plans and audit records. +func (p *Postgres) Name() string { return "postgres" } + +// Detect claims credentials a postgres scanner/seed emitted (Source == "postgres"). +func (p *Postgres) Detect(c discover.Credential) bool { return c.Source == "postgres" } + +func (p *Postgres) bin() string { + if p.Bin != "" { + return p.Bin + } + return "psql" +} + +// Rotate mints a new role password, applies it via ALTER USER over the existing +// (old) connection, and returns a vault handle to the rebuilt DSN. It does not +// revoke anything — the in-place ALTER is itself the cutover. +func (p *Postgres) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + u, err := openDSN(v, c.Secret) + if err != nil { + return nil, err + } + user := u.User.Username() + if user == "" { + return nil, errors.New("postgres: dsn has no user") + } + oldPw, _ := u.User.Password() + + newPw, err := genHex(24) + if err != nil { + return nil, err + } + // New password is hex ([0-9a-f]) so the single-quoted literal cannot break + // out of the statement; the identifier is double-quote escaped regardless. + sql := fmt.Sprintf("ALTER USER %s WITH PASSWORD '%s';", quoteIdent(user), newPw) + if _, err := p.psql(ctx, u, oldPw, sql, newPw); err != nil { + return nil, fmt.Errorf("postgres: ALTER USER: %w", err) + } + + nu := *u + nu.User = url.UserPassword(user, newPw) + return v.Store([]byte(nu.String())), nil +} + +// Verify proves the freshly minted DSN authenticates by issuing SELECT 1 with the +// new password. +func (p *Postgres) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + u, err := openDSN(v, newSecret) + if err != nil { + return err + } + pw, _ := u.User.Password() + out, err := p.psql(ctx, u, pw, "SELECT 1;", pw) + if err != nil { + return fmt.Errorf("postgres verify: %w", err) + } + if !strings.Contains(out, "1") { + return fmt.Errorf("postgres verify: unexpected result") + } + return nil +} + +// RevokeOld is a no-op for an in-place engine: the ALTER in Rotate already +// invalidated the previous password. Defined to satisfy the safety-spine ordering. +func (p *Postgres) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + return nil +} + +// psql runs one SQL statement against the DSN's host/db as the DSN's user, +// authenticating with password via PGPASSWORD and feeding sql on stdin (keeping +// any secret out of argv). redact, if non-empty, is scrubbed from captured output +// before it can reach an error string. +func (p *Postgres) psql(ctx context.Context, u *url.URL, password, sql, redact string) (string, error) { + host := u.Hostname() + port := u.Port() + if port == "" { + port = "5432" + } + db := strings.TrimPrefix(u.Path, "/") + if db == "" { + db = u.User.Username() + } + cmd := exec.CommandContext(ctx, p.bin(), + "-v", "ON_ERROR_STOP=1", "-X", "-w", + "-h", host, "-p", port, "-U", u.User.Username(), "-d", db, + "-tA") + cmd.Env = append(os.Environ(), "PGPASSWORD="+password) + cmd.Stdin = strings.NewReader(sql + "\n") + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + err := cmd.Run() + so, se := out.String(), errb.String() + if redact != "" { + so = strings.ReplaceAll(so, redact, "***") + se = strings.ReplaceAll(se, redact, "***") + } + if err != nil { + return so, fmt.Errorf("%v: %s", err, strings.TrimSpace(se)) + } + return so, nil +} + +// openDSN reads a DSN secret from the vault and parses it, rejecting non-postgres +// schemes. The DSN string is transient and never persisted. +func openDSN(v *vault.Vault, h *vault.Handle) (*url.URL, error) { + buf, err := v.Open(h) + if err != nil { + return nil, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return nil, fmt.Errorf("postgres: parse dsn: %w", err) + } + if u.Scheme != "postgres" && u.Scheme != "postgresql" { + return nil, fmt.Errorf("postgres: unexpected dsn scheme %q", u.Scheme) + } + return u, nil +} + +func genHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + s := hex.EncodeToString(b) + for i := range b { + b[i] = 0 + } + return s, nil +} + +func quoteIdent(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` +} diff --git a/internal/rotate/postgres_test.go b/internal/rotate/postgres_test.go new file mode 100644 index 0000000..41e592e --- /dev/null +++ b/internal/rotate/postgres_test.go @@ -0,0 +1,104 @@ +package rotate + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// writeFakePsql writes a stub `psql` that emulates password auth against a state +// file holding the role's CURRENT password. ALTER updates the state (only if the +// caller authenticated with the current password); SELECT 1 succeeds only when +// PGPASSWORD matches the current password. This lets a test prove a real cutover: +// after Rotate the OLD password must stop authenticating. +func writeFakePsql(t *testing.T, statePath string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "psql") + script := `#!/usr/bin/env bash +set -euo pipefail +sql="$(cat)" +cur="$(cat "` + statePath + `" 2>/dev/null || true)" +case "$sql" in + *"ALTER USER"*|*"ALTER ROLE"*) + if [ "${PGPASSWORD:-}" != "$cur" ]; then echo "FATAL: password authentication failed" >&2; exit 2; fi + np="$(printf '%s' "$sql" | sed -E "s/.*PASSWORD '([^']*)'.*/\1/")" + printf '%s' "$np" > "` + statePath + `" + echo "ALTER ROLE"; exit 0 ;; + *"SELECT 1"*) + if [ "${PGPASSWORD:-}" != "$cur" ]; then echo "FATAL: password authentication failed" >&2; exit 2; fi + echo "1"; exit 0 ;; + *) echo "unrecognized" >&2; exit 1 ;; +esac +` + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestPostgresDetect(t *testing.T) { + pg := &Postgres{} + if !pg.Detect(discover.Credential{Source: "postgres"}) { + t.Error("should detect Source=postgres") + } + if pg.Detect(discover.Credential{Source: "env"}) { + t.Error("should not detect Source=env") + } +} + +func TestPostgresRotateRealCutover(t *testing.T) { + state := filepath.Join(t.TempDir(), "pw") + if err := os.WriteFile(state, []byte("oldpw"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("PGPASSWORD", "") // ensure the driver, not the env, supplies it + pg := &Postgres{Bin: writeFakePsql(t, state)} + + v := vault.New() + defer v.Purge() + oldDSN := "postgres://labapp:oldpw@127.0.0.1:5432/labdb" + cred := discover.Credential{Source: "postgres", Identity: "labapp@labdb", Secret: v.Store([]byte(oldDSN))} + ctx := context.Background() + + newH, err := pg.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + // The fixture's current password must have changed. + if cur, _ := os.ReadFile(state); string(cur) == "oldpw" { + t.Fatal("password not changed by ALTER") + } + + // New secret authenticates... + if err := pg.Verify(ctx, newH, v); err != nil { + t.Errorf("Verify(new) should pass: %v", err) + } + // ...and the OLD credential is now dead (real cutover). + if err := pg.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(), "oldpw") { + // sanity: error must not leak the secret + t.Error("error leaked the old password") + } + + if err := pg.RevokeOld(ctx, cred, v); err != nil { + t.Errorf("RevokeOld (no-op) returned: %v", err) + } +} + +func TestPostgresRejectsNonPostgresDSN(t *testing.T) { + pg := &Postgres{Bin: "/bin/false"} + v := vault.New() + defer v.Purge() + cred := discover.Credential{Source: "postgres", Secret: v.Store([]byte("mysql://u:p@h/db"))} + if _, err := pg.Rotate(context.Background(), cred, v); err == nil { + t.Error("Rotate should reject a non-postgres scheme") + } +} diff --git a/internal/rotate/redis.go b/internal/rotate/redis.go new file mode 100644 index 0000000..260d1c1 --- /dev/null +++ b/internal/rotate/redis.go @@ -0,0 +1,163 @@ +package rotate + +import ( + "bytes" + "context" + "fmt" + "net/url" + "os" + "os/exec" + "strings" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// Redis rotates a Redis instance's password in place, the same in-place pattern as +// Postgres/MySQL. The credential's secret is a redis URL +// ("redis://:pw@host:port" or "redis://user:pw@host:port/db"); rotation mints a new +// password and runs +// +// CONFIG SET requirepass +// +// over the OLD (authenticated) connection. CONFIG SET is itself the cutover — the +// old password stops authenticating new connections immediately — so RevokeOld is a +// no-op and the §3 sealed backup is the recovery path (see docs/ROTATION.md §17). +// +// SECURITY: the OLD password reaches redis-cli only via the REDISCLI_AUTH +// *environment* variable (never argv, never disk). The command — which carries the +// NEW password — is fed on stdin so the new password never appears in the process +// argument list. The new password is hex ([0-9a-f]) so it needs no quoting and +// cannot inject extra tokens. Captured output is scrubbed of the new password +// before any error surfaces. +// +// NOTE: this rotates the legacy single `requirepass` secret (the default-user +// password), which is the form vibe coders actually leak in REDIS_URL. ACL-user +// rotation (`ACL SETUSER`) is a separate future surface. +type Redis struct { + Bin string // redis-cli binary; defaults to "redis-cli" +} + +// init registers the driver. Registration only makes it available; changing a +// credential still requires `rotate --execute` AND INCREDIGO_ALLOW_EXECUTE=1. +func init() { Register(&Redis{}) } + +// Name identifies the driver in plans and audit records. +func (r *Redis) Name() string { return "redis" } + +// Detect claims credentials a redis scanner/seed emitted (Source == "redis"). +func (r *Redis) Detect(c discover.Credential) bool { return c.Source == "redis" } + +func (r *Redis) bin() string { + if r.Bin != "" { + return r.Bin + } + return "redis-cli" +} + +// Rotate mints a new password, applies it via CONFIG SET requirepass over the +// existing (old) connection, and returns a vault handle to the rebuilt URL. +func (r *Redis) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + u, err := openRedisURL(v, c.Secret) + if err != nil { + return nil, err + } + oldPw, _ := u.User.Password() + + newPw, err := genHex(24) + if err != nil { + return nil, err + } + if _, err := r.redis(ctx, u, oldPw, "CONFIG SET requirepass "+newPw, newPw); err != nil { + return nil, fmt.Errorf("redis: CONFIG SET requirepass: %w", err) + } + + nu := *u + nu.User = url.UserPassword(u.User.Username(), newPw) + return v.Store([]byte(nu.String())), nil +} + +// Verify proves the freshly minted URL authenticates by issuing PING with the new +// password. +func (r *Redis) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + u, err := openRedisURL(v, newSecret) + if err != nil { + return err + } + pw, _ := u.User.Password() + out, err := r.redis(ctx, u, pw, "PING", pw) + if err != nil { + return fmt.Errorf("redis verify: %w", err) + } + if !strings.Contains(out, "PONG") { + return fmt.Errorf("redis verify: unexpected result") + } + return nil +} + +// RevokeOld is a no-op for an in-place engine: the CONFIG SET in Rotate already +// invalidated the previous password. Defined to satisfy the safety-spine ordering. +func (r *Redis) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + return nil +} + +// redis runs one command against the URL's host/port, authenticating via +// REDISCLI_AUTH and feeding the command on stdin (keeping any secret out of argv). +// redact, if non-empty, is scrubbed from captured output before it can reach an +// error string. +func (r *Redis) redis(ctx context.Context, u *url.URL, password, command, redact string) (string, error) { + host := u.Hostname() + port := u.Port() + if port == "" { + port = "6379" + } + args := []string{"--no-raw", "-h", host, "-p", port} + if db := strings.TrimPrefix(u.Path, "/"); db != "" { + args = append(args, "-n", db) + } + if user := u.User.Username(); user != "" { + args = append(args, "--user", user) + } + cmd := exec.CommandContext(ctx, r.bin(), args...) + cmd.Env = os.Environ() + if password != "" { + cmd.Env = append(cmd.Env, "REDISCLI_AUTH="+password) + } + cmd.Stdin = strings.NewReader(command + "\n") + var out, errb bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errb + err := cmd.Run() + so, se := out.String(), errb.String() + if redact != "" { + so = strings.ReplaceAll(so, redact, "***") + se = strings.ReplaceAll(se, redact, "***") + } + // redis-cli prints server error replies as a "(error) ..." line on stdout and + // often still exits 0 (auth/command failures included), so surface stdout that + // looks like an error even when the process exits clean. + if err == nil && strings.HasPrefix(strings.TrimSpace(so), "(error)") { + return so, fmt.Errorf("redis error: %s", strings.TrimSpace(so)) + } + if err != nil { + return so, fmt.Errorf("%v: %s", err, strings.TrimSpace(se+" "+so)) + } + return so, nil +} + +// openRedisURL reads a redis URL secret from the vault and parses it, accepting the +// redis:// and rediss:// schemes. The URL string is transient and never persisted. +func openRedisURL(v *vault.Vault, h *vault.Handle) (*url.URL, error) { + buf, err := v.Open(h) + if err != nil { + return nil, err + } + u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) + if err != nil { + return nil, fmt.Errorf("redis: parse url: %w", err) + } + if u.Scheme != "redis" && u.Scheme != "rediss" { + return nil, fmt.Errorf("redis: unexpected url scheme %q", u.Scheme) + } + return u, nil +} diff --git a/internal/rotate/redis_test.go b/internal/rotate/redis_test.go new file mode 100644 index 0000000..de24a36 --- /dev/null +++ b/internal/rotate/redis_test.go @@ -0,0 +1,102 @@ +package rotate + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// writeFakeRedisCli writes a stub `redis-cli` that emulates AUTH against a state +// file holding the instance's CURRENT requirepass. CONFIG SET requirepass updates +// the state (only if REDISCLI_AUTH matched the current password); PING succeeds only +// when REDISCLI_AUTH matches. This lets a test prove a real cutover: after Rotate +// the OLD password must stop authenticating. It models redis-cli's behaviour of +// printing "(error) ..." replies on stdout while still exiting 0. +func writeFakeRedisCli(t *testing.T, statePath string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "redis-cli") + script := `#!/usr/bin/env bash +set -uo pipefail +cmd="$(cat)" +cur="$(cat "` + statePath + `" 2>/dev/null || true)" +# If a password is set on the instance, an authless or wrong-password client fails. +if [ -n "$cur" ] && [ "${REDISCLI_AUTH:-}" != "$cur" ]; then + echo "(error) WRONGPASS invalid username-password pair"; exit 0 +fi +case "$cmd" in + "CONFIG SET requirepass "*) + np="${cmd#CONFIG SET requirepass }" + printf '%s' "$np" > "` + statePath + `" + echo "OK"; exit 0 ;; + "PING"*) + echo "PONG"; exit 0 ;; + *) echo "(error) ERR unknown command"; exit 0 ;; +esac +` + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestRedisDetect(t *testing.T) { + r := &Redis{} + if !r.Detect(discover.Credential{Source: "redis"}) { + t.Error("should detect Source=redis") + } + if r.Detect(discover.Credential{Source: "mysql"}) { + t.Error("should not detect Source=mysql") + } +} + +func TestRedisRotateRealCutover(t *testing.T) { + state := filepath.Join(t.TempDir(), "pw") + if err := os.WriteFile(state, []byte("oldpw"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("REDISCLI_AUTH", "") // ensure the driver, not the env, supplies it + r := &Redis{Bin: writeFakeRedisCli(t, state)} + + v := vault.New() + defer v.Purge() + oldURL := "redis://:oldpw@127.0.0.1:6379" + cred := discover.Credential{Source: "redis", Identity: "redis@127.0.0.1", Secret: v.Store([]byte(oldURL))} + ctx := context.Background() + + newH, err := r.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + if cur, _ := os.ReadFile(state); string(cur) == "oldpw" { + t.Fatal("password not changed by CONFIG SET requirepass") + } + + if err := r.Verify(ctx, newH, v); err != nil { + t.Errorf("Verify(new) should pass: %v", err) + } + if err := r.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(), "oldpw") { + t.Error("error leaked the old password") + } + + if err := r.RevokeOld(ctx, cred, v); err != nil { + t.Errorf("RevokeOld (no-op) returned: %v", err) + } +} + +func TestRedisRejectsNonRedisURL(t *testing.T) { + r := &Redis{Bin: "/bin/false"} + v := vault.New() + defer v.Purge() + cred := discover.Credential{Source: "redis", Secret: v.Store([]byte("postgres://u:p@h/db"))} + if _, err := r.Rotate(context.Background(), cred, v); err == nil { + t.Error("Rotate should reject a non-redis scheme") + } +} diff --git a/internal/rotate/rotate.go b/internal/rotate/rotate.go index 12060c2..3f880c8 100644 --- a/internal/rotate/rotate.go +++ b/internal/rotate/rotate.go @@ -31,8 +31,11 @@ type Rotator interface { Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) // Verify proves the newly minted secret actually authenticates. Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error - // RevokeOld retires the previous credential — only after verify + store. - RevokeOld(ctx context.Context, c discover.Credential) error + // RevokeOld retires the previous credential — only after verify + store. It + // receives the vault so a driver can read the OLD secret (c.Secret) when + // revocation needs it (e.g. SSH: derive the old public key to remove from + // authorized_keys). In-place engines (postgres) ignore it. + RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error } var ( diff --git a/internal/rotate/sshkey.go b/internal/rotate/sshkey.go new file mode 100644 index 0000000..5e18494 --- /dev/null +++ b/internal/rotate/sshkey.go @@ -0,0 +1,310 @@ +package rotate + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "errors" + "fmt" + "os" + "os/user" + "path/filepath" + "sync" + "time" + + "golang.org/x/crypto/ssh" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// SSHKey rotates an SSH key pair by minting a brand-new keypair and swapping which +// PUBLIC key is trusted in an authorized_keys file. The credential's secret is the +// OpenSSH-format PRIVATE key (the whole key file). Rotation: +// +// 1. mint a fresh ed25519 keypair entirely in memory; +// 2. install the NEW public key into authorized_keys (old key still trusted); +// 3. return a vault handle to the NEW private key (PEM) — never written to disk; +// 4. Verify dials the SSH authority with the new key to prove it authenticates; +// 5. RevokeOld removes the OLD public key from authorized_keys — 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, PEM-marshalled, parsed, and used to +// authenticate ENTIRELY in process (golang.org/x/crypto/ssh). It is handed to +// the vault and to gopass; it never touches the filesystem here. No external +// ssh-keygen, no temp key file. +// - Only PUBLIC keys are written to authorized_keys. A leak-check in the tests +// asserts no "PRIVATE KEY" material reaches authorized_keys. +// +// Targeting is the credential's Meta (so discovery can point it at the right +// authority) with struct-field / sensible defaults: +// +// Meta["ssh_addr"] host:port to dial (default 127.0.0.1:22) +// Meta["ssh_user"] login user (default current user) +// Meta["authorized_keys"] file to edit (default ~/.ssh/authorized_keys) +type SSHKey struct { + Addr string // default 127.0.0.1:22 + User string // default current OS user + AuthKeysPath string // default ~/.ssh/authorized_keys + HostKey ssh.HostKeyCallback // default InsecureIgnoreHostKey (we own the host) + // Dial is injectable so tests can point at an in-process SSH server. Defaults + // to ssh.Dial. + Dial func(network, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) + + // mu guards inFlight: Verify in the Execute spine receives no credential, so + // Rotate records the credential it just rotated (for its Meta targeting) and + // Verify reads it back. Execute is sequential, so a single in-flight target is + // sufficient; the mutex keeps it race-clean under -race. + mu sync.Mutex + inFlight discover.Credential +} + +// init registers the ssh driver (self-register, like database/sql). Availability +// alone changes nothing; `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate it. +func init() { Register(&SSHKey{}) } + +// Name identifies the driver in plans and audit records. +func (s *SSHKey) Name() string { return "ssh" } + +// Detect claims credentials the ssh scanner/seed emitted (Source == "ssh"). +func (s *SSHKey) Detect(c discover.Credential) bool { return c.Source == "ssh" } + +func (s *SSHKey) addr(c discover.Credential) string { + if a := c.Meta["ssh_addr"]; a != "" { + return a + } + if s.Addr != "" { + return s.Addr + } + return "127.0.0.1:22" +} + +func (s *SSHKey) loginUser(c discover.Credential) string { + if u := c.Meta["ssh_user"]; u != "" { + return u + } + if s.User != "" { + return s.User + } + if u, err := user.Current(); err == nil { + return u.Username + } + return "" +} + +func (s *SSHKey) authKeys(c discover.Credential) string { + if p := c.Meta["authorized_keys"]; p != "" { + return p + } + if s.AuthKeysPath != "" { + return s.AuthKeysPath + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".ssh", "authorized_keys") +} + +func (s *SSHKey) hostKey() ssh.HostKeyCallback { + if s.HostKey != nil { + return s.HostKey + } + // We rotate keys for hosts the operator owns; the trust boundary here is the + // key auth, not host identity. Host-key pinning is a separate concern. + return ssh.InsecureIgnoreHostKey() +} + +func (s *SSHKey) dial() func(string, string, *ssh.ClientConfig) (*ssh.Client, error) { + if s.Dial != nil { + return s.Dial + } + return ssh.Dial +} + +// Rotate mints a new ed25519 keypair in memory, installs the new PUBLIC key into +// authorized_keys (leaving the old one trusted), and returns a vault handle to the +// new PRIVATE key in OpenSSH PEM form. It does not revoke the old key. +func (s *SSHKey) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { + // Validate we can parse the OLD key first — refuse to rotate something we don't + // actually hold a usable secret for (encrypted keys without a passphrase, junk). + if _, err := s.signer(v, c.Secret); err != nil { + return nil, fmt.Errorf("ssh: old key unusable: %w", err) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("ssh: generate key: %w", err) + } + + comment := fmt.Sprintf("incredigo-rotated-%s-%d", c.Identity, time.Now().Unix()) + block, err := ssh.MarshalPrivateKey(priv, comment) + if err != nil { + return nil, fmt.Errorf("ssh: marshal private key: %w", err) + } + privPEM := pem.EncodeToMemory(block) + + sshPub, err := ssh.NewPublicKey(pub) + if err != nil { + return nil, fmt.Errorf("ssh: new public key: %w", err) + } + newLine := ssh.MarshalAuthorizedKey(sshPub) // " ...\n" + // strip generated comment-less trailing and re-append with our comment for + // human readability in authorized_keys (the blob is unchanged). + newLine = append(bytes.TrimRight(newLine, "\n"), []byte(" "+comment+"\n")...) + + if err := appendAuthorizedKey(s.authKeys(c), newLine); err != nil { + return nil, fmt.Errorf("ssh: install new public key: %w", err) + } + + // Record this credential's targeting for the credential-less Verify that + // Execute calls next. + s.mu.Lock() + s.inFlight = c + s.mu.Unlock() + + // Hand the new private key to the vault; Store wipes privPEM. + return v.Store(privPEM), nil +} + +// Verify proves the newly minted private key authenticates by dialing the SSH +// authority and opening a session-less connection. +func (s *SSHKey) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { + signer, err := s.signer(v, newSecret) + if err != nil { + return fmt.Errorf("ssh verify: %w", err) + } + s.mu.Lock() + c := s.inFlight + s.mu.Unlock() + return s.dialWith(ctx, c, signer) +} + +// dialWith opens (and immediately closes) an SSH connection authenticated by +// signer. The credential supplies targeting Meta; an empty Credential uses +// struct/default targeting (Verify passes one). It uses the resolved addr/user. +func (s *SSHKey) dialWith(ctx context.Context, c discover.Credential, signer ssh.Signer) error { + cfg := &ssh.ClientConfig{ + User: s.loginUser(c), + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: s.hostKey(), + Timeout: 10 * time.Second, + } + cli, err := s.dial()("tcp", s.addr(c), cfg) + if err != nil { + return fmt.Errorf("ssh dial %s: %w", s.addr(c), err) + } + _ = cli.Close() + return nil +} + +// RevokeOld removes the OLD public key from authorized_keys. It derives the public +// key from the old PRIVATE secret in the vault, so it removes exactly the line the +// old key would authenticate with (compared by the base64 key blob, ignoring +// options/comment differences). +func (s *SSHKey) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { + signer, err := s.signer(v, c.Secret) + if err != nil { + return fmt.Errorf("ssh revoke: old key unusable: %w", err) + } + oldLine := ssh.MarshalAuthorizedKey(signer.PublicKey()) + return removeAuthorizedKey(s.authKeys(c), oldLine) +} + +// signer reads an OpenSSH private key from the vault and parses it into a Signer, +// keeping the key material out of any Go string. +func (s *SSHKey) signer(v *vault.Vault, h *vault.Handle) (ssh.Signer, error) { + if h == nil { + return nil, errors.New("nil secret handle") + } + buf, err := v.Open(h) + if err != nil { + return nil, err + } + signer, err := ssh.ParsePrivateKey(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("parse private key: %w", err) + } + return signer, nil +} + +// authKeyBlob returns the base64 key field (the 2nd whitespace-separated token) of +// an authorized_keys line, which uniquely identifies the key independent of any +// leading options or trailing comment. Returns nil if the line has no key blob. +func authKeyBlob(line []byte) []byte { + line = bytes.TrimSpace(line) + if len(line) == 0 || line[0] == '#' { + return nil + } + fields := bytes.Fields(line) + // Plain form: " [comment]". Options form: + // " [comment]". The key blob is the long base64 + // token that starts with the SSH key-type magic ("AAAA"); find it robustly. + for _, f := range fields { + if bytes.HasPrefix(f, []byte("AAAA")) { + return f + } + } + return nil +} + +// appendAuthorizedKey adds line to the authorized_keys file if an entry with the +// same key blob is not already present. It creates ~/.ssh (0700) and the file +// (0600) as needed. line must end in a newline. +func appendAuthorizedKey(path string, line []byte) error { + want := authKeyBlob(line) + if want == nil { + return errors.New("authorized_keys: refusing to write a line with no key blob") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + if existing, err := os.ReadFile(path); err == nil { + for _, l := range bytes.Split(existing, []byte("\n")) { + if bytes.Equal(authKeyBlob(l), want) { + return nil // already trusted — idempotent + } + } + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + defer f.Close() + if !bytes.HasSuffix(line, []byte("\n")) { + line = append(line, '\n') + } + _, err = f.Write(line) + return err +} + +// removeAuthorizedKey rewrites authorized_keys without any line whose key blob +// matches line's, via a temp file + atomic rename so a crash never leaves a +// truncated file (which could lock the operator out). +func removeAuthorizedKey(path string, line []byte) error { + target := authKeyBlob(line) + if target == nil { + return errors.New("authorized_keys: cannot revoke a line with no key blob") + } + existing, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil // nothing to revoke + } + return err + } + var keep [][]byte + for _, l := range bytes.Split(existing, []byte("\n")) { + if bytes.Equal(authKeyBlob(l), target) { + continue // drop the old key + } + keep = append(keep, l) + } + out := bytes.Join(keep, []byte("\n")) + + tmp := path + ".incredigo.tmp" + if err := os.WriteFile(tmp, out, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/rotate/sshkey_test.go b/internal/rotate/sshkey_test.go new file mode 100644 index 0000000..8e5669d --- /dev/null +++ b/internal/rotate/sshkey_test.go @@ -0,0 +1,230 @@ +package rotate + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/pem" + "net" + "os" + "path/filepath" + "testing" + "time" + + "golang.org/x/crypto/ssh" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// TestSSHKeyRotateRealCutover proves the full SSH cutover against an in-process +// SSH server: the new key authenticates, the old key is dropped from +// authorized_keys only after verify, and no private-key material is ever written +// to authorized_keys. +func TestSSHKeyRotateRealCutover(t *testing.T) { + v := vault.New() + defer v.Purge() + + authKeys := filepath.Join(t.TempDir(), "authorized_keys") + + // Seed an OLD keypair: pubkey trusted in authorized_keys, private PEM in vault. + oldPub, oldPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("gen old key: %v", err) + } + oldSSHPub, err := ssh.NewPublicKey(oldPub) + if err != nil { + t.Fatalf("old ssh pub: %v", err) + } + if err := os.WriteFile(authKeys, ssh.MarshalAuthorizedKey(oldSSHPub), 0o600); err != nil { + t.Fatalf("seed authorized_keys: %v", err) + } + oldBlock, err := ssh.MarshalPrivateKey(oldPriv, "old") + if err != nil { + t.Fatalf("marshal old priv: %v", err) + } + oldPEM := pem.EncodeToMemory(oldBlock) + oldPEMCopy := append([]byte(nil), oldPEM...) // for later inequality check (Store wipes oldPEM) + + oldSigner, err := ssh.NewSignerFromKey(oldPriv) + if err != nil { + t.Fatalf("old signer: %v", err) + } + + addr := startSSHServer(t, authKeys) + + cred := discover.Credential{ + Source: "ssh", + Kind: discover.KindPrivateKey, + Identity: "id_ed25519", + Location: authKeys, + Secret: v.Store(oldPEM), + Meta: map[string]string{ + "ssh_addr": addr, + "ssh_user": "tester", + "authorized_keys": authKeys, + }, + } + + // Baseline: the old key authenticates before any rotation. + if err := canAuth(addr, "tester", oldSigner); err != nil { + t.Fatalf("old key should authenticate at start: %v", err) + } + + s := &SSHKey{} + ctx := context.Background() + + // 1. Rotate — mint new key, install its pubkey (old still trusted). + newH, err := s.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + nb, err := v.Open(newH) + if err != nil { + t.Fatalf("open new secret: %v", err) + } + if bytes.Equal(nb.Bytes(), oldPEMCopy) { + t.Fatal("new private key equals old — rotation minted nothing") + } + if !bytes.Contains(nb.Bytes(), []byte("PRIVATE KEY")) { + t.Fatal("new secret is not a private key PEM") + } + newSigner, err := ssh.ParsePrivateKey(nb.Bytes()) + if err != nil { + t.Fatalf("parse new private key: %v", err) + } + + // After Rotate both keys authenticate (verify-before-revoke ordering). + if err := canAuth(addr, "tester", oldSigner); err != nil { + t.Fatalf("old key must still work before revoke: %v", err) + } + if err := canAuth(addr, "tester", newSigner); err != nil { + t.Fatalf("new key must authenticate after install: %v", err) + } + + // 2. Verify(new) via the driver. + if err := s.Verify(ctx, newH, v); err != nil { + t.Fatalf("Verify(new): %v", err) + } + + // 3. RevokeOld — drop the old pubkey. + if err := s.RevokeOld(ctx, cred, v); err != nil { + t.Fatalf("RevokeOld: %v", err) + } + + // Cutover assertions: old dead, new alive. + if err := canAuth(addr, "tester", oldSigner); err == nil { + t.Fatal("old key must be rejected after revoke") + } + if err := canAuth(addr, "tester", newSigner); err != nil { + t.Fatalf("new key must still authenticate after revoke: %v", err) + } + + // Hard Rule #3: no private-key material in authorized_keys. + final, err := os.ReadFile(authKeys) + if err != nil { + t.Fatalf("read authorized_keys: %v", err) + } + if bytes.Contains(final, []byte("PRIVATE KEY")) { + t.Fatal("authorized_keys leaked private key material") + } + // Exactly one key should remain (the new one). + if got := bytes.Count(bytes.TrimSpace(final), []byte("ssh-ed25519")); got != 1 { + t.Fatalf("expected exactly 1 trusted key after cutover, found %d", got) + } +} + +// canAuth dials addr and authenticates with signer, returning nil on success. +func canAuth(addr, user string, signer ssh.Signer) error { + cfg := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 5 * time.Second, + } + cli, err := ssh.Dial("tcp", addr, cfg) + if err != nil { + return err + } + return cli.Close() +} + +// startSSHServer launches an in-process SSH server on a random localhost port that +// authorizes any public key currently present in authKeysPath (re-read per attempt, +// so it reflects rotation). It returns the listen address; the listener is closed +// at test cleanup. +func startSSHServer(t *testing.T, authKeysPath string) 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 // listener closed + } + go serveSSH(conn, hostSigner, authKeysPath) + } + }() + return ln.Addr().String() +} + +func serveSSH(conn net.Conn, hostKey ssh.Signer, authKeysPath string) { + cfg := &ssh.ServerConfig{ + PublicKeyCallback: func(_ ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + if keyAuthorized(authKeysPath, key) { + return &ssh.Permissions{}, nil + } + return nil, ssh.ErrNoAuth + }, + } + cfg.AddHostKey(hostKey) + + sshConn, chans, reqs, err := ssh.NewServerConn(conn, cfg) + if err != nil { + conn.Close() + return + } + go ssh.DiscardRequests(reqs) + go func() { + for ch := range chans { + ch.Reject(ssh.Prohibited, "no channels") + } + }() + _ = sshConn +} + +// keyAuthorized reports whether key matches any entry in authKeysPath. +func keyAuthorized(path string, key ssh.PublicKey) bool { + data, err := os.ReadFile(path) + if err != nil { + return false + } + want := key.Marshal() + for len(data) > 0 { + pub, _, _, rest, err := ssh.ParseAuthorizedKey(data) + if err != nil { + break + } + if bytes.Equal(pub.Marshal(), want) { + return true + } + data = rest + } + return false +} diff --git a/internal/rotate/wireguard.go b/internal/rotate/wireguard.go new file mode 100644 index 0000000..af392aa --- /dev/null +++ b/internal/rotate/wireguard.go @@ -0,0 +1,262 @@ +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 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 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 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 allowed-ips` is one +// "\t[,...]" 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 ` 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 ` 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 + } +} diff --git a/internal/rotate/wireguard_test.go b/internal/rotate/wireguard_test.go new file mode 100644 index 0000000..d886782 --- /dev/null +++ b/internal/rotate/wireguard_test.go @@ -0,0 +1,148 @@ +package rotate + +import ( + "context" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// writeFakeWg writes a stub `wg` that maintains peer/interface state under $WG_STATE +// so a test can prove a real cutover: after Rotate the peer trusts the NEW public +// key; after RevokeOld it no longer trusts the OLD one. It also appends every argv to +// argv.log so a leak-check can assert no PRIVATE key ever reached the argument list +// (private keys must arrive only on stdin). +func writeFakeWg(t *testing.T, state string) string { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "wg") + script := `#!/usr/bin/env bash +set -uo pipefail +S="` + state + `" +echo "$*" >> "$S/argv.log" +cmd="$1"; iface="$2" +peers="$S/$iface.peers" +case "$cmd" in + set) + case "$3" in + private-key) cat > "$S/$iface.priv" ;; # key arrives on stdin, never argv + peer) + pub="$4" + touch "$peers" + grep -Fv "$pub " "$peers" > "$peers.tmp" 2>/dev/null || true + mv "$peers.tmp" "$peers" + if [ "$5" = "allowed-ips" ]; then printf '%s\t%s\n' "$pub" "$6" >> "$peers"; fi + ;; # "$5" = remove just leaves the grep -Fv result (entry dropped) + esac + ;; + show) + case "$3" in + allowed-ips) cat "$peers" 2>/dev/null || true ;; + peers) awk '{print $1}' "$peers" 2>/dev/null || true ;; + esac + ;; +esac +exit 0 +` + if err := os.WriteFile(p, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + return p +} + +func TestWireGuardDetect(t *testing.T) { + w := &WireGuard{} + if !w.Detect(discover.Credential{Source: "wireguard"}) { + t.Error("should detect Source=wireguard") + } + if w.Detect(discover.Credential{Source: "ssh"}) { + t.Error("should not detect Source=ssh") + } +} + +func TestWireGuardRotateRealCutover(t *testing.T) { + state := t.TempDir() + wg := writeFakeWg(t, state) + + // Mint an old keypair the same way the driver would, seed it as the credential, + // and seed the peer (wg1) so it trusts the OLD public key with some allowed-ips. + oldPriv, oldPub, err := genWGKey() + if err != nil { + t.Fatal(err) + } + oldPrivB64 := base64.StdEncoding.EncodeToString(oldPriv) + if err := os.WriteFile(filepath.Join(state, "wg1.peers"), []byte(oldPub+"\t10.0.0.2/32\n"), 0o600); err != nil { + t.Fatal(err) + } + + w := &WireGuard{Bin: wg, Iface: "wg0", PeerIface: "wg1"} + v := vault.New() + defer v.Purge() + cred := discover.Credential{Source: "wireguard", Identity: "wg0", Secret: v.Store([]byte(oldPrivB64))} + ctx := context.Background() + + newH, err := w.Rotate(ctx, cred, v) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + + // Read back the new private key for inequality + leak assertions. + nb, err := v.Open(newH) + if err != nil { + t.Fatal(err) + } + newPrivB64 := strings.TrimSpace(string(nb.Bytes())) + if newPrivB64 == oldPrivB64 { + t.Fatal("private key not changed by rotation") + } + + if err := w.Verify(ctx, newH, v); err != nil { + t.Errorf("Verify(new) should pass: %v", err) + } + + // The new interface private key must have been applied (stdin → state file)... + if got, _ := os.ReadFile(filepath.Join(state, "wg0.priv")); strings.TrimSpace(string(got)) != newPrivB64 { + t.Error("interface private key was not applied") + } + // ...and NO private key may ever have appeared on a wg argument list. + argv, _ := os.ReadFile(filepath.Join(state, "argv.log")) + for _, secret := range []string{oldPrivB64, newPrivB64} { + if strings.Contains(string(argv), secret) { + t.Error("a private key leaked into wg argv") + } + } + // The peer state must hold public keys only, never a private key. + peers, _ := os.ReadFile(filepath.Join(state, "wg1.peers")) + for _, secret := range []string{oldPrivB64, newPrivB64} { + if strings.Contains(string(peers), secret) { + t.Error("a private key leaked into peer state") + } + } + + // Cutover: after RevokeOld the peer drops the OLD pubkey but keeps the NEW one. + if err := w.RevokeOld(ctx, cred, v); err != nil { + t.Fatalf("RevokeOld: %v", err) + } + peers, _ = os.ReadFile(filepath.Join(state, "wg1.peers")) + if strings.Contains(string(peers), oldPub) { + t.Error("peer still trusts the OLD public key after RevokeOld") + } + if newPub, _ := w.pubOf(v, newH); !strings.Contains(string(peers), newPub) { + t.Error("peer should still trust the NEW public key after RevokeOld") + } +} + +func TestWireGuardRejectsBadKey(t *testing.T) { + w := &WireGuard{Bin: "/bin/false"} + v := vault.New() + defer v.Purge() + cred := discover.Credential{Source: "wireguard", Secret: v.Store([]byte("not-base64-or-32-bytes"))} + if _, err := w.Rotate(context.Background(), cred, v); err == nil { + t.Error("Rotate should reject an unusable private key") + } +}