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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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, `"`, `""`) + `"`
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user