rotate: 4 SaaS-token/app-signing drivers + data-separated proof tracking
Add gitlab, cloudflare, ghactions (MOCK-ONLY) and appsecret (LIVE-VM) one-file Rotators, each with a table-driven cutover-proof + leak-check test. gitlab/ cloudflare/ghactions are in-place SaaS-token rolls (self/rotate, value-roll, sealed-secret overwrite) so RevokeOld is a no-op; ghactions seals via nacl/box.SealAnonymous (no new go.mod dep). appsecret regenerates a local app signing secret and rewrites it in place across every target file (atomic, mode- preserving, redacted errors), discoverable via exact-match app-signing key names in env.go. Keep real-rotation code distinct from mock code: how each driver's cutover was validated lives as DATA in internal/rotate/proofs.go (single source of truth) + docs/ROTATION-PROOFS.md, surfaced as a PROOF column in `incredigo rotate` so a MOCK-ONLY driver can never be mistaken for a LIVE-VM one. appsecret proven LIVE-VM against real local files in the sandbox VM. 82 tests green, -race clean on rotate/sink/vault. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// ghaEmu is an in-process GitHub Actions API emulator for ONE repo secret. It owns a
|
||||
// libsodium (NaCl) box keypair, serves the public key, and on PUT decrypts the sealed
|
||||
// value with the private key so a test can assert the value the driver actually wrote.
|
||||
type ghaEmu struct {
|
||||
mu sync.Mutex
|
||||
pub [32]byte
|
||||
priv [32]byte
|
||||
keyID string
|
||||
pat string // the only accepted Bearer token
|
||||
secret string // decrypted current secret value (after a PUT)
|
||||
hasSecret bool
|
||||
}
|
||||
|
||||
func newGHAEmu(pat string) *ghaEmu {
|
||||
pub, priv, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &ghaEmu{pub: *pub, priv: *priv, keyID: "kid-123", pat: pat}
|
||||
}
|
||||
|
||||
func (e *ghaEmu) auth(r *http.Request) bool {
|
||||
return strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") == e.pat
|
||||
}
|
||||
|
||||
func (e *ghaEmu) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
const base = "/repos/octo/lab/actions/secrets"
|
||||
|
||||
mux.HandleFunc(base+"/public-key", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.auth(r) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{
|
||||
"key_id": e.keyID,
|
||||
"key": base64.StdEncoding.EncodeToString(e.pub[:]),
|
||||
})
|
||||
})
|
||||
|
||||
// PUT writes (seals) the value; GET returns metadata only (never the value).
|
||||
mux.HandleFunc(base+"/DEPLOY_TOKEN", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !e.auth(r) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
var body struct {
|
||||
Encrypted string `json:"encrypted_value"`
|
||||
KeyID string `json:"key_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.KeyID != e.keyID {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
sealed, err := base64.StdEncoding.DecodeString(body.Encrypted)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
plain, ok := box.OpenAnonymous(nil, sealed, &e.pub, &e.priv)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
e.mu.Lock()
|
||||
e.secret = string(plain)
|
||||
e.hasSecret = true
|
||||
e.mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case http.MethodGet:
|
||||
e.mu.Lock()
|
||||
has := e.hasSecret
|
||||
e.mu.Unlock()
|
||||
if !has {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]any{"name": "DEPLOY_TOKEN"})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func (e *ghaEmu) stored() string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.secret
|
||||
}
|
||||
|
||||
func TestGHActionsDetect(t *testing.T) {
|
||||
g := &GHActions{}
|
||||
if !g.Detect(discover.Credential{Source: "ghactions"}) {
|
||||
t.Error("should detect Source=ghactions")
|
||||
}
|
||||
if g.Detect(discover.Credential{Source: "gitlab"}) {
|
||||
t.Error("should not detect Source=gitlab")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHActionsRotateSealsRealValue(t *testing.T) {
|
||||
emu := newGHAEmu("ghp_managementPAT0001")
|
||||
srv := httptest.NewTLSServer(emu.handler())
|
||||
defer srv.Close()
|
||||
host := strings.TrimPrefix(srv.URL, "https://")
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
oldVal := "old-deploy-value-0000"
|
||||
blob := "ghactions://" + host + "/?" + url.Values{
|
||||
"owner": {"octo"}, "repo": {"lab"}, "name": {"DEPLOY_TOKEN"},
|
||||
"pat": {emu.pat}, "val": {oldVal},
|
||||
}.Encode()
|
||||
cred := discover.Credential{
|
||||
Source: "ghactions",
|
||||
Identity: "github actions secret DEPLOY_TOKEN",
|
||||
Secret: v.Store([]byte(blob)),
|
||||
}
|
||||
g := &GHActions{HTTPClient: srv.Client()}
|
||||
ctx := context.Background()
|
||||
|
||||
newH, err := g.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate: %v", err)
|
||||
}
|
||||
|
||||
// The rebuilt blob must carry a fresh value (different from the old one).
|
||||
nb, _ := v.Open(newH)
|
||||
nu, _ := url.Parse(strings.TrimSpace(string(nb.Bytes())))
|
||||
newVal := nu.Query().Get("val")
|
||||
if newVal == "" || newVal == oldVal {
|
||||
t.Fatalf("blob carries no fresh value (got %q)", newVal)
|
||||
}
|
||||
if nu.Query().Get("name") != "DEPLOY_TOKEN" {
|
||||
t.Errorf("secret name changed: %q", nu.Query().Get("name"))
|
||||
}
|
||||
|
||||
// CUTOVER PROOF: the value GitHub actually received (decrypted from the sealed
|
||||
// box) must equal the new plaintext in the rebuilt blob — proving the roll wrote
|
||||
// the real new value, not a simulation.
|
||||
if got := emu.stored(); got != newVal {
|
||||
t.Fatalf("server stored %q, blob says %q — sealed value mismatch", got, newVal)
|
||||
}
|
||||
if emu.stored() == oldVal {
|
||||
t.Error("old value still stored after rotation")
|
||||
}
|
||||
|
||||
if err := g.Verify(ctx, newH, v); err != nil {
|
||||
t.Errorf("Verify(new) should pass: %v", err)
|
||||
}
|
||||
|
||||
if err := g.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Errorf("RevokeOld (no-op) returned: %v", err)
|
||||
}
|
||||
|
||||
// Leak-check: neither the management PAT nor any secret value may appear in the
|
||||
// credential's non-secret metadata.
|
||||
for _, fld := range []string{cred.Identity} {
|
||||
if strings.Contains(fld, emu.pat) || strings.Contains(fld, newVal) || strings.Contains(fld, oldVal) {
|
||||
t.Error("secret material leaked into credential metadata")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGHActionsRejectsBadSecret(t *testing.T) {
|
||||
g := &GHActions{}
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
// missing owner/repo
|
||||
cred := discover.Credential{Source: "ghactions",
|
||||
Secret: v.Store([]byte("ghactions://api/?name=X&pat=abc&val=y"))}
|
||||
if _, err := g.Rotate(context.Background(), cred, v); err == nil {
|
||||
t.Error("Rotate should reject a secret with no owner/repo")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user