Skip to content

Commit 53ad122

Browse files
sarg3ntclaude
andcommitted
feat(rotation): Phase 1 multi-key keyring plumbing (#72)
Foundation for issue #72's rotation work. Adds the data structures and storage required for N-entry keyrings on both the agent and dashboard sides, with no operator-visible behaviour change yet — rotation endpoints and UI follow in Phase 2. Agent side ---------- - `internal/framework/crypto/keyring.go` — `KeyRing` type with up to `MaxKeyRingEntries = 4` accepted keys, atomic tmpfile+rename on disk, AES-256-GCM (GBE1) encryption when `GEARBOX_AGENT_ENCRYPTION_KEY` is set. Wire token format: `gbx_<6-hex-kid>_<base64url(32 random bytes)>`, with legacy 64-hex tokens still accepted for one release cycle. - `LoadOrCreateKeyRing(keyringPath, legacyAPIKeyPath)` migrates an existing `/var/lib/gearbox-agent/api-key` file into a single keyring entry tagged `kid="legacy"`, role=primary. Legacy file stays on disk as a read-only fallback. - `KeyRingPointer` wraps `atomic.Pointer[KeyRing]` so Phase 2's install/use/remove endpoints can swap the live keyring without middleware restart. Verified by the new auth-middleware test `TestAPIKeyAuth_HotSwapVisibleImmediately`. - `internal/framework/middleware/auth.go` rewritten to take a keyring pointer instead of a static key. Accepts both prefixed and legacy token formats; matched `kid` echoed back as `X-Gearbox-Kid:` header on every authenticated response so the dashboard can detect drift (consumed in Phase 5). Auth with a secondary key logs at INFO so the audit log can later flag "old key still in use after rotation". - New endpoint `GET /api/v1/system/keyring` (authenticated) returns metadata only — kids, roles, created_at, sha256-prefix fingerprint for diagnostic equality checks — never the secret bytes themselves. - `--show-api-key` and `--rotate-api-key` CLI flags work against the keyring; the printed key uses the new `gbx_<kid>_<b64>` wire format the dashboard can paste verbatim. - `GEARBOX_AGENT_KEYRING_PATH` env var (default `<DataDir>/keyring.json`) is now a config field alongside the legacy `HAPROXY_AGENT_API_KEY_PATH`. Dashboard side -------------- - Migration `000002_add_box_agent_keys` adds the `(box_id, kid)`-keyed `box_agent_keys` table and idempotently backfills one `kid='legacy'` row per existing box from `boxes.api_key_encrypted`. The legacy column stays for one release. - `database/box_agent_keys.go` exposes Get/Insert/SetPrimary/Delete/ TouchLastUsed — the storage primitives Phase 2's rotator service composes into the install→use→remove dance. Tests ----- - 19 keyring unit tests covering token parsing (prefixed + legacy + malformed), keyring mutation, file round-trip with and without encryption, legacy api-key migration, and pointer hot-swap. - 8 auth-middleware integration tests covering bearer parsing, kid header echo, secondary-key acceptance, and the live hot-swap path Phase 2 depends on. - 5 storage tests covering primary-key lookup, atomic role flip, delete-refuses-last guard, and last_used_at touch. Carry-overs to Phase 2 (intentional gaps surfaced from this PR) --------------------------------------------------------------- - `DeleteBox` does not yet cascade to `box_agent_keys` (SQLite `PRAGMA foreign_keys` is off in this codebase; enabling it is a broader change). Phase 2's box-delete path will clean dependent rows explicitly. Documented in box_agent_keys_test.go. - The dashboard's `agent.Client` does not yet send `X-Gearbox-Kid` on outbound requests — there's no kid to send while every box's keyring contains only the legacy entry. Phase 2 wires this when the rotator starts mutating keyrings. Refs: research summary and implementation plan posted to #72. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 79f7bb9 commit 53ad122

12 files changed

Lines changed: 1706 additions & 41 deletions

File tree

gearbox-agent/cmd/gearbox-agent/main.go

Lines changed: 69 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -113,29 +113,63 @@ func main() {
113113
os.Exit(1)
114114
}
115115

116-
// Handle API key commands
116+
// Handle API key commands.
117+
//
118+
// Both flags read/write the agent's keyring (issue #72), falling back
119+
// to the legacy api-key file when no keyring is present yet. The
120+
// printed key is the keyring's primary entry, in the
121+
// `gbx_<kid>_<base64url>` wire format the dashboard accepts.
117122
if *showAPIKey {
118-
key, err := crypto.ReadAPIKey(cfg.APIKeyPath)
123+
kr, _, err := crypto.LoadOrCreateKeyRing(cfg.KeyRingPath, cfg.APIKeyPath)
119124
if err != nil {
120-
logger.Error("Failed to read API key", "error", err)
125+
logger.Error("Failed to read keyring", "error", err)
121126
os.Exit(1)
122127
}
123-
fmt.Println(key)
128+
primary := kr.Primary()
129+
if primary == nil {
130+
fmt.Fprintln(os.Stderr, "no primary key found in keyring")
131+
os.Exit(1)
132+
}
133+
fmt.Println(crypto.FormatToken(primary.KID, primary.Secret))
124134
os.Exit(0)
125135
}
126136

127137
if *rotateAPIKey {
128-
key, err := crypto.GenerateAPIKey()
138+
// Generates a fresh primary key and writes it to the keyring,
139+
// replacing whatever was primary before. The agent reads the
140+
// keyring on startup so the new key takes effect after restart.
141+
// (Phase 2 will add a hot-reload endpoint that avoids restart.)
142+
kr, _, err := crypto.LoadOrCreateKeyRing(cfg.KeyRingPath, cfg.APIKeyPath)
129143
if err != nil {
130-
logger.Error("Failed to generate API key", "error", err)
144+
logger.Error("Failed to load keyring", "error", err)
145+
os.Exit(1)
146+
}
147+
// Replace all entries with a single fresh primary. CLI rotate is
148+
// the operator escape hatch — it's intentionally not the same as
149+
// the controller-driven three-phase rotation; rather, it wipes
150+
// the slate so a fresh dashboard pairing can take over.
151+
kid, kerr := crypto.NewKID()
152+
if kerr != nil {
153+
logger.Error("Failed to generate key id", "error", kerr)
154+
os.Exit(1)
155+
}
156+
secret, serr := crypto.NewSecret()
157+
if serr != nil {
158+
logger.Error("Failed to generate secret", "error", serr)
131159
os.Exit(1)
132160
}
133-
if err := crypto.WriteAPIKey(cfg.APIKeyPath, key); err != nil {
134-
logger.Error("Failed to write API key", "error", err)
161+
fresh := crypto.KeyRingEntry{
162+
KID: kid,
163+
Secret: secret,
164+
Role: "primary",
165+
}
166+
kr.Entries = []crypto.KeyRingEntry{fresh}
167+
if err := crypto.SaveKeyRing(cfg.KeyRingPath, kr); err != nil {
168+
logger.Error("Failed to write keyring", "error", err)
135169
os.Exit(1)
136170
}
137171
fmt.Println("New API key generated:")
138-
fmt.Println(key)
172+
fmt.Println(crypto.FormatToken(fresh.KID, fresh.Secret))
139173
fmt.Println("\nRestart the service for the new key to take effect.")
140174
os.Exit(0)
141175
}
@@ -244,19 +278,33 @@ func main() {
244278
"to enable AES-256-GCM encryption-at-rest.")
245279
}
246280

247-
// Load or create API key
248-
apiKey, isNewKey, err := crypto.LoadOrCreateAPIKey(cfg.APIKeyPath)
281+
// Load or create the keyring. Issue #72 replaced the single-key model
282+
// with an N-entry keyring to support zero-downtime rotation. Existing
283+
// installs that still have an api-key file (no keyring.json yet) are
284+
// migrated transparently: the on-disk hex key becomes the keyring's
285+
// primary entry with kid="legacy", and the file is left in place as
286+
// a read-only fallback for one release cycle.
287+
keyring, isNewKey, err := crypto.LoadOrCreateKeyRing(cfg.KeyRingPath, cfg.APIKeyPath)
249288
if err != nil {
250-
logger.Error("Failed to initialize API key", "error", err)
289+
logger.Error("Failed to initialize keyring", "error", err)
251290
os.Exit(1)
252291
}
292+
keyringPtr := crypto.NewKeyRingPointer(keyring)
253293
if isNewKey {
254-
logger.Warn("NEW API KEY GENERATED - Save this key, it will not be shown again!")
255-
// Print to stdout (not logger) so it doesn't appear in system logs
256-
fmt.Printf("API Key: %s\n", apiKey)
257-
logger.Info("API key saved", "path", cfg.APIKeyPath)
294+
primary := keyring.Primary()
295+
if primary != nil {
296+
logger.Warn("NEW API KEY GENERATED - Save this key, it will not be shown again!")
297+
// Print to stdout (not logger) so it doesn't appear in system logs
298+
fmt.Printf("API Key: %s\n", crypto.FormatToken(primary.KID, primary.Secret))
299+
logger.Info("Keyring saved", "path", cfg.KeyRingPath, "kid", primary.KID)
300+
}
258301
} else {
259-
logger.Info("API key loaded from file")
302+
primary := keyring.Primary()
303+
kid := "<none>"
304+
if primary != nil {
305+
kid = primary.KID
306+
}
307+
logger.Info("Keyring loaded", "path", cfg.KeyRingPath, "entries", len(keyring.Entries), "primary_kid", kid)
260308
}
261309

262310
// Load or create TLS certificates
@@ -398,7 +446,7 @@ func main() {
398446
// Create and start API server
399447
serverCfg := api.ServerConfig{
400448
ListenAddr: cfg.ListenAddr,
401-
APIKey: apiKey,
449+
KeyRing: keyringPtr,
402450
CertFile: tlsCfg.CertPath,
403451
KeyFile: tlsCfg.KeyPath,
404452
Version: Version,
@@ -493,12 +541,14 @@ func main() {
493541
// backoff layered on top — see 2026-05 audit P1-7).
494542
rateLimiter := middleware.DefaultRateLimiter(logger)
495543
authBackoff := middleware.DefaultBackoffTracker(logger)
544+
keyRingHandler := api.NewKeyRingHandler(server.KeyRing(), logger)
496545
pluginRouter := server.Router().Group(func(r chi.Router) {
497546
r.Use(middleware.RateLimitMiddleware(rateLimiter))
498-
r.Use(middleware.APIKeyAuth(server.APIKey(), logger, authBackoff))
547+
r.Use(middleware.APIKeyAuth(server.KeyRing(), logger, authBackoff))
499548
})
500549
gearManager.RegisterRoutes(pluginRouter)
501550
gearManager.RegisterSystemRoutes(pluginRouter)
551+
keyRingHandler.RegisterRoutes(pluginRouter)
502552

503553
logger.Info("Plugin system initialized",
504554
"plugins", gear.Names(),
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package api
2+
3+
import (
4+
"encoding/json"
5+
"log/slog"
6+
"net/http"
7+
8+
"github.com/go-chi/chi/v5"
9+
10+
"github.com/sarg3nt/gearbox-agent/internal/framework/crypto"
11+
)
12+
13+
// KeyRingHandler exposes the agent's keyring metadata over the HTTP API.
14+
// Phase 1 ships only the read endpoint; Phase 2 adds install/use/remove
15+
// for the controller-driven three-phase rotation flow (issue #72).
16+
type KeyRingHandler struct {
17+
keyring *crypto.KeyRingPointer
18+
logger *slog.Logger
19+
}
20+
21+
// NewKeyRingHandler wires a handler around the agent's keyring pointer.
22+
func NewKeyRingHandler(keyring *crypto.KeyRingPointer, logger *slog.Logger) *KeyRingHandler {
23+
return &KeyRingHandler{keyring: keyring, logger: logger}
24+
}
25+
26+
// RegisterRoutes mounts the keyring endpoints on r. The caller is
27+
// responsible for putting r behind the API-key auth middleware.
28+
func (h *KeyRingHandler) RegisterRoutes(r chi.Router) {
29+
r.Get("/api/v1/system/keyring", h.handleGet)
30+
}
31+
32+
// keyRingResponse is what the API returns. Never includes secret bytes.
33+
type keyRingResponse struct {
34+
Version int `json:"version"`
35+
Entries []crypto.KeyRingMetadata `json:"entries"`
36+
}
37+
38+
// handleGet returns the keyring metadata — kids, roles, creation times,
39+
// and short fingerprints — but never the actual secrets.
40+
//
41+
// @Summary Get agent keyring metadata
42+
// @Description Returns the currently-installed API keys' metadata. Secrets are NEVER exposed. The fingerprint is the first 8 hex chars of sha256(secret); useful to verify the dashboard has the same secret without round-tripping the secret itself.
43+
// @Tags system
44+
// @Security BearerAuth
45+
// @Produce json
46+
// @Success 200 {object} keyRingResponse
47+
// @Failure 401 {string} string "Unauthorized"
48+
// @Router /api/v1/system/keyring [get]
49+
func (h *KeyRingHandler) handleGet(w http.ResponseWriter, _ *http.Request) {
50+
kr := h.keyring.Load()
51+
resp := keyRingResponse{
52+
Version: kr.Version,
53+
Entries: kr.Snapshot(),
54+
}
55+
w.Header().Set("Content-Type", "application/json")
56+
if err := json.NewEncoder(w).Encode(resp); err != nil {
57+
h.logger.Error("encode keyring response failed", "error", err)
58+
}
59+
}

gearbox-agent/internal/api/server.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
_ "github.com/sarg3nt/gearbox-agent/docs" // Swagger docs
1919
"github.com/sarg3nt/gearbox-agent/internal/api/console"
20+
"github.com/sarg3nt/gearbox-agent/internal/framework/crypto"
2021
"github.com/sarg3nt/gearbox-agent/internal/framework/events"
2122
frameworkmiddleware "github.com/sarg3nt/gearbox-agent/internal/framework/middleware"
2223
)
@@ -26,15 +27,15 @@ type Server struct {
2627
httpServer *http.Server
2728
router chi.Router
2829
logger *slog.Logger
29-
apiKey string
30+
keyring *crypto.KeyRingPointer
3031
certFile string
3132
keyFile string
3233
}
3334

3435
// ServerConfig holds configuration for the API server.
3536
type ServerConfig struct {
3637
ListenAddr string
37-
APIKey string
38+
KeyRing *crypto.KeyRingPointer
3839
CertFile string
3940
KeyFile string
4041
Version string
@@ -147,7 +148,7 @@ func NewServer(cfg ServerConfig) *Server {
147148
// Protected API routes (require API key auth)
148149
r.Group(func(r chi.Router) {
149150
r.Use(frameworkmiddleware.RateLimitMiddleware(rateLimiter))
150-
r.Use(frameworkmiddleware.APIKeyAuth(cfg.APIKey, cfg.Logger, authBackoff))
151+
r.Use(frameworkmiddleware.APIKeyAuth(cfg.KeyRing, cfg.Logger, authBackoff))
151152

152153
// Core endpoints (not handled by plugins)
153154
r.Get("/api/v1/metadata", handlers.Metadata)
@@ -193,7 +194,7 @@ func NewServer(cfg ServerConfig) *Server {
193194
},
194195
router: r,
195196
logger: cfg.Logger,
196-
apiKey: cfg.APIKey,
197+
keyring: cfg.KeyRing,
197198
certFile: cfg.CertFile,
198199
keyFile: cfg.KeyFile,
199200
}
@@ -204,9 +205,10 @@ func (s *Server) Router() chi.Router {
204205
return s.router
205206
}
206207

207-
// APIKey returns the server's API key for middleware configuration.
208-
func (s *Server) APIKey() string {
209-
return s.apiKey
208+
// KeyRing returns the server's keyring pointer for middleware
209+
// configuration on plugin routes registered after server construction.
210+
func (s *Server) KeyRing() *crypto.KeyRingPointer {
211+
return s.keyring
210212
}
211213

212214
// Logger returns the server's logger.

gearbox-agent/internal/framework/config/config.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,18 @@ type Config struct {
3535
// whitespace around each entry is trimmed, empty entries are dropped.
3636
TLSHosts []string
3737

38-
// API key settings
39-
APIKeyPath string
38+
// API key settings.
39+
//
40+
// APIKeyPath is the legacy single-key file; retained for backwards
41+
// compatibility with installs that pre-date the keyring. On startup
42+
// the agent migrates its contents into a single keyring entry
43+
// (kid="legacy", role=primary) and the file is left in place as a
44+
// read-only fallback for one release cycle.
45+
//
46+
// KeyRingPath is the new N-entry rotating keyring (issue #72).
47+
// Defaults to <DataDir>/keyring.json.
48+
APIKeyPath string
49+
KeyRingPath string
4050

4151
// Data directory for state, certs, etc.
4252
DataDir string
@@ -174,6 +184,7 @@ func Load() (*Config, error) {
174184
}
175185
cfg.TLSHosts = parseCommaList(os.Getenv("HAPROXY_AGENT_TLS_HOSTS"))
176186
cfg.APIKeyPath = getEnvOrDefault("HAPROXY_AGENT_API_KEY_PATH", cfg.DataDir+"/api-key")
187+
cfg.KeyRingPath = getEnvOrDefault("GEARBOX_AGENT_KEYRING_PATH", cfg.DataDir+"/keyring.json")
177188

178189
// Logging
179190
if v := os.Getenv("HAPROXY_AGENT_LOG_LEVEL"); v != "" {

0 commit comments

Comments
 (0)