35f19c007a
Each driver is real-service code only (no test awareness; tests inject just an HTTPClient/Bin), following the established patterns: - mongo: in-place changeUserPassword over the old connection; no secret on argv (host/db on the URI, both passwords on stdin, output scrubbed). - npm: create token -> /-/whoami -> delete-by-key; blob records the key. - gcp: service-account KEY rotation; mints an RS256 JWT signed with the SA's own private key (crypto/rsa, stdlib) to self-auth the IAM create/verify/delete. - twilio: API Key create -> get -> delete, Basic auth (KeySid/secret). - flyio: GraphQL — a managing token mints/deletes the rotated deploy token. - k8s: create a service-account-token Secret, await the populated token, verify against the SA, delete the old Secret (invalidates the old token). All 6 register their honest proof level (MOCK-ONLY) in proofs.go + ROTATION-PROOFS.md; mongo/npm/k8s are LIVE-VM candidates pending real-target VM POCs. Emulators enforce credential validity (gcp verifies the JWT signature), tests assert Verify(old) fails after revoke + a secret-leak canary. Full suite: 103 green, -race clean on rotate/sink/vault. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
220 lines
6.0 KiB
Go
220 lines
6.0 KiB
Go
package rotate
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// npmEmu is a minimal npm registry token API emulator that ENFORCES token validity:
|
|
// a token authenticates (whoami) only while it exists, POST mints a new token+key,
|
|
// and DELETE-by-key makes a token stop working — so a test can prove a real cutover
|
|
// (old token dead, new token alive).
|
|
type npmEmu struct {
|
|
mu sync.Mutex
|
|
byKey map[string]string // key -> token value
|
|
byValue map[string]string // token value -> key
|
|
user string
|
|
srv *httptest.Server
|
|
}
|
|
|
|
func newNPMEmu(t *testing.T, user, seedKey, seedValue string) *npmEmu {
|
|
e := &npmEmu{
|
|
byKey: map[string]string{seedKey: seedValue},
|
|
byValue: map[string]string{seedValue: seedKey},
|
|
user: user,
|
|
}
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/-/whoami", e.handleWhoami)
|
|
mux.HandleFunc("/-/npm/v1/tokens", e.handleCreate)
|
|
mux.HandleFunc("/-/npm/v1/tokens/token/", e.handleDelete)
|
|
e.srv = httptest.NewTLSServer(mux)
|
|
t.Cleanup(e.srv.Close)
|
|
return e
|
|
}
|
|
|
|
func (e *npmEmu) valid(token string) bool {
|
|
e.mu.Lock()
|
|
defer e.mu.Unlock()
|
|
_, ok := e.byValue[token]
|
|
return ok
|
|
}
|
|
|
|
// bearer returns the token from an "Authorization: Bearer <v>" header.
|
|
func bearer(r *http.Request) string {
|
|
h := r.Header.Get("Authorization")
|
|
if strings.HasPrefix(h, "Bearer ") {
|
|
return strings.TrimPrefix(h, "Bearer ")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (e *npmEmu) handleWhoami(w http.ResponseWriter, r *http.Request) {
|
|
if !e.valid(bearer(r)) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
json.NewEncoder(w).Encode(map[string]any{"username": e.user})
|
|
}
|
|
|
|
func (e *npmEmu) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !e.valid(bearer(r)) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
raw := make([]byte, 20)
|
|
rand.Read(raw)
|
|
val := "npm_" + hex.EncodeToString(raw)
|
|
keyRaw := make([]byte, 16)
|
|
rand.Read(keyRaw)
|
|
key := hex.EncodeToString(keyRaw)
|
|
e.mu.Lock()
|
|
e.byKey[key] = val
|
|
e.byValue[val] = key
|
|
e.mu.Unlock()
|
|
w.WriteHeader(http.StatusCreated)
|
|
json.NewEncoder(w).Encode(map[string]any{"token": val, "key": key})
|
|
}
|
|
|
|
func (e *npmEmu) handleDelete(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodDelete {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
if !e.valid(bearer(r)) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
|
key := parts[len(parts)-1]
|
|
e.mu.Lock()
|
|
if v, ok := e.byKey[key]; ok {
|
|
delete(e.byValue, v)
|
|
delete(e.byKey, key)
|
|
}
|
|
e.mu.Unlock()
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func TestNPMDetect(t *testing.T) {
|
|
n := &NPM{}
|
|
if !n.Detect(discover.Credential{Source: "npm"}) {
|
|
t.Error("should detect Source=npm")
|
|
}
|
|
if n.Detect(discover.Credential{Source: "gitea"}) {
|
|
t.Error("should not detect Source=gitea")
|
|
}
|
|
}
|
|
|
|
func TestNPMRotateRealCutover(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
ctx := context.Background()
|
|
|
|
const user, seedKey, oldTok = "labpub", "seedkey0123456789", "npm_0123456789abcdef0123456789abcdef01234567"
|
|
emu := newNPMEmu(t, user, seedKey, oldTok)
|
|
|
|
host := strings.TrimPrefix(emu.srv.URL, "https://")
|
|
// npm:// scheme normalises to https, matching the TLS emulator; the rebuilt blob
|
|
// (which Verify re-parses) uses the same npm→https mapping.
|
|
oldBlob := "npm://" + host + "/?user=" + user + "&token=" + oldTok + "&key=" + seedKey
|
|
cred := discover.Credential{
|
|
Source: "npm",
|
|
Kind: discover.KindToken,
|
|
Identity: user + " @ npm",
|
|
Location: "imported/npm/labpub",
|
|
Secret: v.Store([]byte(oldBlob)),
|
|
}
|
|
n := &NPM{HTTPClient: emu.srv.Client()}
|
|
|
|
if !emu.valid(oldTok) {
|
|
t.Fatal("seed token should be valid at start")
|
|
}
|
|
|
|
// 1. Rotate — create a new token (old still valid).
|
|
newH, err := n.Rotate(ctx, cred, v)
|
|
if err != nil {
|
|
t.Fatalf("Rotate: %v", err)
|
|
}
|
|
ns, err := parseNPMSecret(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 ns.key == "" || ns.key == seedKey {
|
|
t.Fatalf("new secret must carry a fresh key, got %q", ns.key)
|
|
}
|
|
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 := n.Verify(ctx, newH, v); err != nil {
|
|
t.Fatalf("Verify(new): %v", err)
|
|
}
|
|
|
|
// 3. RevokeOld — delete the old token by key.
|
|
if err := n.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 := n.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 TestNPMRevokeRequiresKey(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
n := &NPM{}
|
|
// Blob with token but no key= → RevokeOld must refuse (guided), not silently pass.
|
|
cred := discover.Credential{
|
|
Source: "npm",
|
|
Secret: v.Store([]byte("npm://registry.npmjs.org/?user=bob&token=npm_abc123")),
|
|
}
|
|
err := n.RevokeOld(context.Background(), cred, v)
|
|
if err == nil || !strings.Contains(err.Error(), "no recorded key") {
|
|
t.Fatalf("expected revoke-without-key error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNPMRejectsBadSecret(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
n := &NPM{}
|
|
// no token
|
|
cred := discover.Credential{Source: "npm", Secret: v.Store([]byte("npm://registry.npmjs.org/?user=bob"))}
|
|
if _, err := n.Rotate(context.Background(), cred, v); err == nil {
|
|
t.Error("Rotate should reject a secret with no token")
|
|
}
|
|
}
|