rotate: nine rotation drivers + verify-before-revoke execute spine

Implements the Rotator interface across all four rotation patterns, each a
self-contained one-file driver self-registering via init():

  in-place DB password   postgres, mysql (3-stmt unprivileged fallback), redis
  local keypair + propagate  sshkey (ed25519), wireguard (clamped curve25519)
  provider-API token         gitea PAT, mullvad device key
  cloud-key self-identifying  aws IAM access key (hand-rolled SigV4, no SDK dep)

Spine (execute.go) enforces Hard Rule #2: backup -> rotate -> verify(new) ->
store -> re-read+verify -> revoke-old; dryrun.go keeps --execute gated. Each
driver ships a table-driven test proving real cutover (old secret stops
authenticating) and asserting no secret substring leaks to argv/Meta/errors.
Promotes golang.org/x/crypto to a direct dependency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-18 14:48:24 -07:00
parent 71a74e7166
commit 59af53dcc0
25 changed files with 4116 additions and 3 deletions
+1 -1
View File
@@ -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
)
+339
View File
@@ -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://<AccessKeyId>:<SecretAccessKey>@aws/?region=<region>[&endpoint=<url>]
//
// - 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 <Error><Code> 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)
}
+222
View File
@@ -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=<keyID>/<date>/<region>/<service>/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, `<ErrorResponse><Error><Code>InvalidClientTokenId</Code><Message>key revoked</Message></Error></ErrorResponse>`)
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, `<CreateAccessKeyResponse><CreateAccessKeyResult><AccessKey>`+
`<UserName>%s</UserName><AccessKeyId>%s</AccessKeyId><Status>Active</Status>`+
`<SecretAccessKey>%s</SecretAccessKey></AccessKey></CreateAccessKeyResult>`+
`</CreateAccessKeyResponse>`, e.user, newID, newSecret)
case "DeleteAccessKey":
id := r.Form.Get("AccessKeyId")
e.mu.Lock()
delete(e.valid, id)
e.mu.Unlock()
io.WriteString(w, `<DeleteAccessKeyResponse><ResponseMetadata><RequestId>1</RequestId></ResponseMetadata></DeleteAccessKeyResponse>`)
case "GetCallerIdentity":
fmt.Fprintf(w, `<GetCallerIdentityResponse><GetCallerIdentityResult>`+
`<Arn>arn:aws:iam::123456789012:user/%s</Arn><UserId>AIDAEMULATED</UserId>`+
`<Account>123456789012</Account></GetCallerIdentityResult></GetCallerIdentityResponse>`, e.user)
default:
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, `<ErrorResponse><Error><Code>InvalidAction</Code></Error></ErrorResponse>`)
}
}
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")
}
}
+65
View File
@@ -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
}
+74
View File
@@ -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" }
+108
View File
@@ -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
}
+256
View File
@@ -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://<user>:<token>@<host>[:port]/?name=<token-name>[&pw=<mgmt-password>]
//
// - <token> the PAT being rotated (the userinfo password).
// - <user> 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)
}
+198
View File
@@ -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 <v>" 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)
}
}
+290
View File
@@ -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://<account_number>@<api-host>/?device=<device_id>&privkey=<base64-privkey>
//
// - <account_number> the Mullvad account number — the sole auth secret. Exchanged
// for a short-lived access token (POST /auth/v1/token); the
// account number itself never goes in a Bearer header.
// - <device_id> the device this credential represents (DELETE target on
// revoke; the new credential carries the NEW device's id).
// - privkey= the device's WireGuard PRIVATE key (base64). Only the derived
// PUBLIC key is ever sent to the API; the private key stays in
// the vault blob.
//
// SECURITY: account number, private key, and access token live only in the vault and
// in-process; none reach Identity/Meta/logs/argv. This driver is unit-tested against
// an httptest emulator only — it never authenticates to api.mullvad.net in tests
// (that needs a real paid account).
type Mullvad struct {
// HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout
// client using http.DefaultTransport.
HTTPClient *http.Client
}
// init self-registers the driver. Availability alone changes nothing; the
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
func init() { Register(&Mullvad{}) }
// Name identifies the driver in plans and audit records.
func (m *Mullvad) Name() string { return "mullvad" }
// Detect claims credentials tagged Source == "mullvad".
func (m *Mullvad) Detect(c discover.Credential) bool { return c.Source == "mullvad" }
func (m *Mullvad) client() *http.Client {
if m.HTTPClient != nil {
return m.HTTPClient
}
return &http.Client{Timeout: 15 * time.Second}
}
// mullvadSecret is the parsed credential blob. None of its fields are ever logged.
type mullvadSecret struct {
base string // scheme://host
account string
device string
privB64 string
}
func parseMullvadSecret(v *vault.Vault, h *vault.Handle) (mullvadSecret, error) {
buf, err := v.Open(h)
if err != nil {
return mullvadSecret{}, err
}
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
if err != nil {
return mullvadSecret{}, fmt.Errorf("mullvad: parse secret url: %w", err)
}
if u.Scheme != "mullvad" && u.Scheme != "http" && u.Scheme != "https" {
return mullvadSecret{}, fmt.Errorf("mullvad: unexpected scheme %q", u.Scheme)
}
if u.User == nil || u.User.Username() == "" {
return mullvadSecret{}, fmt.Errorf("mullvad: secret has no account number")
}
q := u.Query()
scheme := u.Scheme
if scheme == "mullvad" {
scheme = "https"
}
s := mullvadSecret{
base: scheme + "://" + u.Host,
account: u.User.Username(),
device: q.Get("device"),
privB64: q.Get("privkey"),
}
if s.privB64 == "" {
return mullvadSecret{}, fmt.Errorf("mullvad: secret has no privkey")
}
return s, nil
}
// build re-encodes a mullvadSecret into the single-line blob form (preserving the
// original scheme as "mullvad" so the in-vault form stays stable).
func (s mullvadSecret) build() string {
u := &url.URL{
Scheme: "mullvad",
Host: hostOf(s.base),
User: url.User(s.account),
Path: "/",
}
q := url.Values{}
q.Set("device", s.device)
q.Set("privkey", s.privB64)
u.RawQuery = q.Encode()
return u.String()
}
// accessToken exchanges the account number for a short-lived API access token.
func (m *Mullvad) accessToken(ctx context.Context, s mullvadSecret) (string, error) {
body, _ := json.Marshal(map[string]string{"account_number": s.account})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/auth/v1/token", bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := m.client().Do(req)
if err != nil {
return "", fmt.Errorf("mullvad: auth: %w", err)
}
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf("mullvad: auth: status %s", resp.Status)
}
var tok struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil {
return "", fmt.Errorf("mullvad: decode auth: %w", err)
}
if tok.AccessToken == "" {
return "", fmt.Errorf("mullvad: auth: empty access token")
}
return tok.AccessToken, nil
}
// Rotate mints a new WireGuard keypair, registers it as a NEW device, and returns a
// vault handle to a rebuilt blob carrying the new device id + new private key. It
// does not delete the old device.
func (m *Mullvad) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
s, err := parseMullvadSecret(v, c.Secret)
if err != nil {
return nil, err
}
token, err := m.accessToken(ctx, s)
if err != nil {
return nil, err
}
newPriv, newPub, err := genWGKey()
if err != nil {
return nil, fmt.Errorf("mullvad: generate key: %w", err)
}
newPrivB64 := base64.StdEncoding.EncodeToString(newPriv)
zeroBytes(newPriv)
body, _ := json.Marshal(map[string]string{"pubkey": newPub})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/accounts/v1/devices", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := m.client().Do(req)
if err != nil {
return nil, fmt.Errorf("mullvad: create device: %w", err)
}
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("mullvad: create device: status %s", resp.Status)
}
var dev struct {
ID string `json:"id"`
Pubkey string `json:"pubkey"`
}
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
return nil, fmt.Errorf("mullvad: decode device: %w", err)
}
if dev.ID == "" {
return nil, fmt.Errorf("mullvad: create device: empty device id")
}
ns := s // carry base/account forward; swap device + key
ns.device = dev.ID
ns.privB64 = newPrivB64
return v.Store([]byte(ns.build())), nil
}
// Verify proves the new device exists carrying the public key derived from the new
// private key.
func (m *Mullvad) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
s, err := parseMullvadSecret(v, newSecret)
if err != nil {
return err
}
wantPub, err := pubFromPrivB64(s.privB64)
if err != nil {
return fmt.Errorf("mullvad verify: %w", err)
}
token, err := m.accessToken(ctx, s)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/accounts/v1/devices/"+url.PathEscape(s.device), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := m.client().Do(req)
if err != nil {
return fmt.Errorf("mullvad verify: %w", err)
}
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("mullvad verify: get device status %s", resp.Status)
}
var dev struct {
Pubkey string `json:"pubkey"`
}
if err := json.NewDecoder(resp.Body).Decode(&dev); err != nil {
return fmt.Errorf("mullvad verify: decode device: %w", err)
}
if dev.Pubkey != wantPub {
return fmt.Errorf("mullvad verify: device pubkey does not match the new key")
}
return nil
}
// RevokeOld deletes the OLD device. The new device is already registered+verified, so
// removing the old one closes its WireGuard key.
func (m *Mullvad) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
s, err := parseMullvadSecret(v, c.Secret)
if err != nil {
return err
}
if s.device == "" {
return fmt.Errorf("mullvad revoke: old credential has no device id — remove it manually (guided)")
}
token, err := m.accessToken(ctx, s)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.base+"/accounts/v1/devices/"+url.PathEscape(s.device), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := m.client().Do(req)
if err != nil {
return fmt.Errorf("mullvad revoke: %w", err)
}
defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
return fmt.Errorf("mullvad revoke: delete device status %s", resp.Status)
}
return nil
}
// pubFromPrivB64 derives a base64 Curve25519 public key from a base64 private key,
// keeping the private bytes out of any retained string.
func pubFromPrivB64(privB64 string) (string, error) {
priv, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privB64))
if err != nil {
return "", fmt.Errorf("decode private key: %w", err)
}
if len(priv) != 32 {
zeroBytes(priv)
return "", fmt.Errorf("private key is %d bytes, want 32", len(priv))
}
pub, err := curve25519.X25519(priv, curve25519.Basepoint)
zeroBytes(priv)
if err != nil {
return "", fmt.Errorf("derive public key: %w", err)
}
return base64.StdEncoding.EncodeToString(pub), nil
}
+180
View File
@@ -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-<account>.
if r.Header.Get("Authorization") != "Bearer tok-"+e.account {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
switch {
case r.Method == http.MethodPost && r.URL.Path == "/accounts/v1/devices":
var in struct {
Pubkey string `json:"pubkey"`
}
raw, _ := io.ReadAll(r.Body)
body := string(raw)
e.mu.Lock()
e.bodies = append(e.bodies, body)
json.Unmarshal(raw, &in)
id := "dev-new"
e.devices[id] = in.Pubkey
e.mu.Unlock()
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"id": id, "pubkey": in.Pubkey})
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/accounts/v1/devices/"):
id := strings.TrimPrefix(r.URL.Path, "/accounts/v1/devices/")
e.mu.Lock()
pub, ok := e.devices[id]
e.mu.Unlock()
if !ok {
http.Error(w, "no device", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(map[string]string{"pubkey": pub})
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/accounts/v1/devices/"):
id := strings.TrimPrefix(r.URL.Path, "/accounts/v1/devices/")
e.mu.Lock()
_, ok := e.devices[id]
delete(e.devices, id)
e.mu.Unlock()
if !ok {
http.Error(w, "no device", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "not found", http.StatusNotFound)
}
}
}
func TestMullvadDetect(t *testing.T) {
m := &Mullvad{}
if !m.Detect(discover.Credential{Source: "mullvad"}) {
t.Error("should detect Source=mullvad")
}
if m.Detect(discover.Credential{Source: "wireguard"}) {
t.Error("should not detect Source=wireguard")
}
}
func TestMullvadRotateRealCutover(t *testing.T) {
const account = "1234567890123456"
oldPriv, oldPub, err := genWGKey()
if err != nil {
t.Fatal(err)
}
oldPrivB64 := base64.StdEncoding.EncodeToString(oldPriv)
emu := &mullvadEmu{account: account, devices: map[string]string{"dev-old": oldPub}}
srv := httptest.NewTLSServer(emu.handler())
defer srv.Close()
// Seed the credential pointing at the TLS test server (https scheme), carrying
// the old device + old private key.
su, _ := url.Parse(srv.URL)
su.User = url.User(account)
su.Path = "/"
q := url.Values{}
q.Set("device", "dev-old")
q.Set("privkey", oldPrivB64)
su.RawQuery = q.Encode()
m := &Mullvad{HTTPClient: srv.Client()}
v := vault.New()
defer v.Purge()
cred := discover.Credential{Source: "mullvad", Identity: "mullvad-device", Secret: v.Store([]byte(su.String()))}
ctx := context.Background()
newH, err := m.Rotate(ctx, cred, v)
if err != nil {
t.Fatalf("Rotate: %v", err)
}
nb, err := v.Open(newH)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(nb.Bytes()), "dev-old") {
t.Error("new credential should point at the new device, not dev-old")
}
if err := m.Verify(ctx, newH, v); err != nil {
t.Errorf("Verify(new) should pass: %v", err)
}
// Leak checks: the API only ever saw PUBLIC keys; the old private key must not
// appear in any request body nor in any stored device pubkey.
for _, body := range emu.bodies {
if strings.Contains(body, oldPrivB64) {
t.Error("a private key leaked into a request body")
}
}
for _, pub := range emu.devices {
if pub == oldPrivB64 {
t.Error("a private key was stored as a device pubkey")
}
}
// Cutover: revoke the OLD device, then Verify against the old credential must
// fail (its device is gone).
if err := m.RevokeOld(ctx, cred, v); err != nil {
t.Fatalf("RevokeOld: %v", err)
}
if err := m.Verify(ctx, cred.Secret, v); err == nil {
t.Error("Verify(old) must fail after RevokeOld — old device should be deleted")
} else if strings.Contains(err.Error(), oldPrivB64) || strings.Contains(err.Error(), account) {
t.Error("error leaked the account number or private key")
}
}
func TestMullvadRejectsBadSecret(t *testing.T) {
m := &Mullvad{HTTPClient: http.DefaultClient}
v := vault.New()
defer v.Purge()
cred := discover.Credential{Source: "mullvad", Secret: v.Store([]byte("mullvad://acct@api/?device=d"))} // no privkey
if _, err := m.Rotate(context.Background(), cred, v); err == nil {
t.Error("Rotate should reject a secret with no privkey")
}
}
+187
View File
@@ -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 '<newpw>'
//
// 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
}
+99
View File
@@ -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")
}
}
+80
View File
@@ -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...)
}
+117
View File
@@ -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
}
+214
View File
@@ -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://<user>:<password>@<host>[: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
}
+186
View File
@@ -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")
}
}
+176
View File
@@ -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, `"`, `""`) + `"`
}
+104
View File
@@ -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")
}
}
+163
View File
@@ -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 <newpw>
//
// 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
}
+102
View File
@@ -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")
}
}
+5 -2
View File
@@ -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 (
+310
View File
@@ -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) // "<type> <base64> ...\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: "<type> <base64> [comment]". Options form:
// "<options> <type> <base64> [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)
}
+230
View File
@@ -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
}
+262
View File
@@ -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 <iface> private-key`),
// fed on stdin so it never lands in argv or on disk;
// 3. teach the PEER (the other end) to trust the NEW public key, carrying over the
// old peer's allowed-ips (`wg set <peer_iface> peer <newpub> allowed-ips …`);
// 4. return a vault handle to the NEW private key (base64) — never written to disk;
// 5. Verify proves the peer now trusts the new public key;
// 6. RevokeOld removes the OLD public key from the peer — only after the new key is
// verified and stored (Hard Rule #2, verify-new-before-revoke-old).
//
// SECURITY (Hard Rule #3 — no plaintext secrets on disk, ever):
// - The new private key is generated and used ENTIRELY in process; it reaches `wg`
// only via stdin (`wg set <iface> private-key /dev/stdin`), never argv, never a
// key file. Only PUBLIC keys appear on the peer/`wg set peer` argument list. A
// leak-check in the tests asserts no private-key material reaches any wg call's
// argv or the peer state.
//
// Targeting (Meta, with struct-field / sensible defaults), so this models the
// self-hosted single-host case (an interface and its single peer the operator owns);
// a client→server split would propagate step 3/6 to the server over SSH/API instead:
//
// Meta["wg_iface"] local interface whose key we rotate (default wg0)
// Meta["wg_peer_iface"] the peer interface that must trust our key (default wg1)
// Meta["wg_allowed_ips"] fallback allowed-ips if the peer has none for the old key
type WireGuard struct {
Bin string // wg binary; defaults to "wg"
Iface string // default wg0
PeerIface string // default wg1
AllowedIPs string // fallback allowed-ips for the re-added peer
// mu guards inFlight: Verify in the Execute spine receives no credential, so
// Rotate records what it just rotated and Verify reads it back. Execute is
// sequential; the mutex keeps the single in-flight target race-clean.
mu sync.Mutex
inFlight discover.Credential
}
// init registers the driver. Availability alone changes nothing; `rotate --execute`
// + INCREDIGO_ALLOW_EXECUTE=1 gate any real change.
func init() { Register(&WireGuard{}) }
// Name identifies the driver in plans and audit records.
func (w *WireGuard) Name() string { return "wireguard" }
// Detect claims credentials a wireguard scanner/seed emitted (Source == "wireguard").
func (w *WireGuard) Detect(c discover.Credential) bool { return c.Source == "wireguard" }
func (w *WireGuard) bin() string {
if w.Bin != "" {
return w.Bin
}
return "wg"
}
func (w *WireGuard) iface(c discover.Credential) string {
if i := c.Meta["wg_iface"]; i != "" {
return i
}
if w.Iface != "" {
return w.Iface
}
return "wg0"
}
func (w *WireGuard) peerIface(c discover.Credential) string {
if i := c.Meta["wg_peer_iface"]; i != "" {
return i
}
if w.PeerIface != "" {
return w.PeerIface
}
return "wg1"
}
func (w *WireGuard) allowedIPsFallback(c discover.Credential) string {
if a := c.Meta["wg_allowed_ips"]; a != "" {
return a
}
return w.AllowedIPs
}
// Rotate mints a new keypair in memory, applies the new private key to the local
// interface, teaches the peer to trust the new public key (carrying over the old
// peer's allowed-ips), and returns a vault handle to the new private key (base64).
// It does not remove the old key.
func (w *WireGuard) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
oldPub, err := w.pubOf(v, c.Secret)
if err != nil {
return nil, fmt.Errorf("wireguard: old key unusable: %w", err)
}
newPriv, newPub, err := genWGKey()
if err != nil {
return nil, fmt.Errorf("wireguard: generate key: %w", err)
}
// Carry over the allowed-ips the peer currently routes to our OLD key, so the
// re-added entry keeps the same routing. Fall back to Meta/struct if absent.
allowed := w.peerAllowedIPs(ctx, c, oldPub)
if allowed == "" {
allowed = w.allowedIPsFallback(c)
}
if allowed == "" {
zeroBytes(newPriv)
return nil, errors.New("wireguard: no allowed-ips for peer (set Meta wg_allowed_ips)")
}
// Apply the new private key to our interface via stdin — never argv, never disk.
newPrivB64 := base64.StdEncoding.EncodeToString(newPriv)
if _, err := w.wgStdin(ctx, newPrivB64+"\n", "set", w.iface(c), "private-key", "/dev/stdin"); err != nil {
zeroBytes(newPriv)
return nil, fmt.Errorf("wireguard: set interface key: %w", err)
}
// Teach the peer to trust the NEW public key (old still trusted until RevokeOld).
if _, err := w.wg(ctx, "set", w.peerIface(c), "peer", newPub, "allowed-ips", allowed); err != nil {
zeroBytes(newPriv)
return nil, fmt.Errorf("wireguard: add peer pubkey: %w", err)
}
w.mu.Lock()
w.inFlight = c
w.mu.Unlock()
h := v.Store([]byte(newPrivB64))
zeroBytes(newPriv)
return h, nil
}
// Verify proves the peer now trusts the public key derived from the new private key.
func (w *WireGuard) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
newPub, err := w.pubOf(v, newSecret)
if err != nil {
return fmt.Errorf("wireguard verify: %w", err)
}
w.mu.Lock()
c := w.inFlight
w.mu.Unlock()
out, err := w.wg(ctx, "show", w.peerIface(c), "peers")
if err != nil {
return fmt.Errorf("wireguard verify: %w", err)
}
if !strings.Contains(out, newPub) {
return errors.New("wireguard verify: peer does not trust the new public key")
}
return nil
}
// RevokeOld removes the OLD public key from the peer. It derives the public key from
// the old PRIVATE secret in the vault, so it removes exactly the peer entry the old
// key authenticated with.
func (w *WireGuard) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
oldPub, err := w.pubOf(v, c.Secret)
if err != nil {
return fmt.Errorf("wireguard revoke: old key unusable: %w", err)
}
if _, err := w.wg(ctx, "set", w.peerIface(c), "peer", oldPub, "remove"); err != nil {
return fmt.Errorf("wireguard revoke: %w", err)
}
return nil
}
// peerAllowedIPs reads the allowed-ips the peer currently maps to pub, or "" if the
// peer has no entry for it. Output of `wg show <iface> allowed-ips` is one
// "<pubkey>\t<ip>[,<ip>...]" row per peer.
func (w *WireGuard) peerAllowedIPs(ctx context.Context, c discover.Credential, pub string) string {
out, err := w.wg(ctx, "show", w.peerIface(c), "allowed-ips")
if err != nil {
return ""
}
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == pub {
return fields[1]
}
}
return ""
}
// pubOf reads a base64 WireGuard private key from the vault and returns its base64
// public key, keeping the private bytes out of any retained Go string.
func (w *WireGuard) pubOf(v *vault.Vault, h *vault.Handle) (string, error) {
if h == nil {
return "", errors.New("nil secret handle")
}
buf, err := v.Open(h)
if err != nil {
return "", err
}
return pubFromPrivB64(strings.TrimSpace(string(buf.Bytes())))
}
// wg runs `wg <args...>` with no stdin and returns trimmed stdout, redacting nothing
// because only public keys / interface names ever appear here.
func (w *WireGuard) wg(ctx context.Context, args ...string) (string, error) {
return w.wgStdin(ctx, "", args...)
}
// wgStdin runs `wg <args...>` feeding stdin (used to pass a private key to
// `set ... private-key /dev/stdin` without it touching argv or disk).
func (w *WireGuard) wgStdin(ctx context.Context, stdin string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, w.bin(), args...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return out.String(), fmt.Errorf("%v: %s", err, strings.TrimSpace(errb.String()))
}
return strings.TrimSpace(out.String()), nil
}
// genWGKey mints a clamped Curve25519 private key and its public key, the way
// `wg genkey | wg pubkey` would, entirely in memory.
func genWGKey() (priv []byte, pubB64 string, err error) {
priv = make([]byte, 32)
if _, err = rand.Read(priv); err != nil {
return nil, "", err
}
// Curve25519 clamping (RFC 7748 §5).
priv[0] &= 248
priv[31] &= 127
priv[31] |= 64
pub, err := curve25519.X25519(priv, curve25519.Basepoint)
if err != nil {
zeroBytes(priv)
return nil, "", err
}
return priv, base64.StdEncoding.EncodeToString(pub), nil
}
func zeroBytes(b []byte) {
for i := range b {
b[i] = 0
}
}
+148
View File
@@ -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")
}
}