Skip to content

Commit d174a75

Browse files
sarg3ntclaude
andcommitted
feat(rotation): Phase 2 install/use/remove endpoints + rotator service (#72)
Builds on Phase 1's keyring plumbing with the active machinery needed to actually rotate keys: three new agent endpoints implementing the controller-orchestrated three-phase dance (install → use → remove), the dashboard-side rotator service that drives it, and the `X-Gearbox-Kid` request/response header that Phase 5 will use for drift detection. Agent endpoints --------------- `POST /api/v1/system/keyring/install` - Body `{kid, secret_b64, role?}`. New entries default to `secondary`; the controller flips primary in a separate `/use` call. - Idempotent on `(kid, same_secret)` — re-installing returns 200 with current state. Same kid with a different secret returns 409 so a divergence between dashboard and agent surfaces loudly. - Returns 507 when the keyring is at `MaxKeyRingEntries` (4) — guards against runaway growth from a buggy rotator. `POST /api/v1/system/keyring/use` - Body `{kid}`. Flips named entry to primary, demotes the prior primary to secondary. Both stay accepted; the agent doesn't distinguish primary vs secondary for inbound auth. `DELETE /api/v1/system/keyring/{kid}` - Refuses 409 on the only remaining entry — agent never bricks itself. All three serialize through a handler-level mutex so concurrent controller calls can't race their read-modify-write of the on-disk keyring. The atomic-tmpfile+rename + `atomic.Pointer` swap from Phase 1 means the auth middleware sees the new keyring on the very next request after the swap, no restart. Dashboard side -------------- `agent.Client` - New `NewClientWithKID(url, key, kid)` constructor + `WithKID(kid)` setter. Existing `NewClient` callers untouched. - All outbound requests now go through a `setAuthHeaders(req)` helper that sets `Authorization: Bearer …` and, when the client was built with a kid, the `X-Gearbox-Kid` request header. Agent middleware echoes the matched kid in the response header of the same name; Phase 5 compares the two to detect drift. - New `KeyRingGet`, `KeyRingInstall(kid, secret, role)`, `KeyRingUse(kid)`, `KeyRingDelete(kid)` methods. `services/agent_keyring` - New package containing the `Rotator`, which composes `RotateBox(boxID, overlap)` from the install/use/remove primitives: decrypt current primary → build authenticated client → install new key on agent as secondary → persist new key to box_agent_keys → flip primary on agent → flip primary in DB (which also stamps `retired_at` on the old entry). - `CleanupRetiredKeys(boxID, overlap)` removes any entries whose `retired_at` is older than the overlap window. Uses `time.Since` for the cutoff comparison so SQLite-driver timezone behaviour doesn't bite (modernc.org/sqlite scans bare DATETIMEs in local time; comparing against `time.Now()` in UTC would otherwise mis- fire). Removes from agent first, then DB; tolerates the agent having already lost the entry (404) or refusing to remove the last key (409) since neither leaves us in a bad state. - `DefaultOverlapWindow = 24h`. Tunable per-call so Phase 4's scheduler can pick the operator's configured value. `SetBoxPrimaryKey` now stamps `retired_at` with `time.Now().UTC()` explicitly rather than letting SQLite emit `CURRENT_TIMESTAMP`, so the value round-trips correctly through the driver's date parser. Tests ----- - 11 keyring-endpoint integration tests (agent side): install adds secondary, install is idempotent, KID collision with different secret returns 409, malformed secret returns 400, use flips primary, use of unknown kid returns 404, delete works, delete of only-remaining-entry returns 409, mutations persist across in-process reload + on-disk reload, keyring file mode is 0600, install over MaxKeyRingEntries returns 507. - 4 rotator integration tests against a `httptest` mock of the agent's keyring API (the dashboard module can't import the agent module): happy path covers full install → use → DB-flip, cleanup removes retired keys past the overlap window, cleanup leaves keys within the overlap window alone, missing-box returns error. - All existing tests in both modules still pass. Refs: Phase 2 of the implementation plan posted to #72. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9fa9e27 commit d174a75

9 files changed

Lines changed: 1361 additions & 29 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ func main() {
542542
// backoff layered on top — see 2026-05 audit P1-7).
543543
rateLimiter := middleware.DefaultRateLimiter(logger)
544544
authBackoff := middleware.DefaultBackoffTracker(logger)
545-
keyRingHandler := api.NewKeyRingHandler(server.KeyRing(), logger)
545+
keyRingHandler := api.NewKeyRingHandler(server.KeyRing(), cfg.KeyRingPath, logger)
546546
pluginRouter := server.Router().Group(func(r chi.Router) {
547547
r.Use(middleware.RateLimitMiddleware(rateLimiter))
548548
r.Use(middleware.APIKeyAuth(server.KeyRing(), logger, authBackoff))

gearbox-agent/internal/api/keyring.go

Lines changed: 273 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,58 @@
11
package api
22

33
import (
4+
"encoding/base64"
45
"encoding/json"
6+
"errors"
57
"log/slog"
68
"net/http"
9+
"strings"
10+
"sync"
711

812
"github.com/go-chi/chi/v5"
913

1014
"github.com/sarg3nt/gearbox-agent/internal/framework/crypto"
1115
)
1216

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).
17+
// KeyRingHandler exposes the agent's keyring metadata and rotation
18+
// endpoints over the HTTP API. The mutation endpoints (install/use/
19+
// remove) drive the controller-orchestrated three-phase rotation from
20+
// issue #72; the metadata endpoint is read-only.
1621
type KeyRingHandler struct {
1722
keyring *crypto.KeyRingPointer
23+
path string
1824
logger *slog.Logger
25+
26+
// mu serializes mutation handlers — keyring writes are tmpfile+
27+
// rename atomic on disk, but two concurrent installs racing would
28+
// still let the second one's read-modify-write step lose the
29+
// first one's changes.
30+
mu sync.Mutex
1931
}
2032

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}
33+
// NewKeyRingHandler wires a handler around the agent's keyring pointer
34+
// and the disk path the keyring is persisted to.
35+
func NewKeyRingHandler(keyring *crypto.KeyRingPointer, path string, logger *slog.Logger) *KeyRingHandler {
36+
return &KeyRingHandler{keyring: keyring, path: path, logger: logger}
2437
}
2538

2639
// RegisterRoutes mounts the keyring endpoints on r. The caller is
2740
// responsible for putting r behind the API-key auth middleware.
2841
func (h *KeyRingHandler) RegisterRoutes(r chi.Router) {
2942
r.Get("/api/v1/system/keyring", h.handleGet)
43+
r.Post("/api/v1/system/keyring/install", h.handleInstall)
44+
r.Post("/api/v1/system/keyring/use", h.handleUse)
45+
r.Delete("/api/v1/system/keyring/{kid}", h.handleRemove)
3046
}
3147

32-
// keyRingResponse is what the API returns. Never includes secret bytes.
48+
// keyRingResponse is what GET returns. Never includes secret bytes.
3349
type keyRingResponse struct {
34-
Version int `json:"version"`
35-
Entries []crypto.KeyRingMetadata `json:"entries"`
50+
Version int `json:"version"`
51+
Entries []crypto.KeyRingMetadata `json:"entries"`
3652
}
3753

38-
// handleGet returns the keyring metadata — kids, roles, creation times,
39-
// and short fingerprints — but never the actual secrets.
54+
// handleGet returns keyring metadata — kids, roles, creation times,
55+
// fingerprints. Secrets are never exposed.
4056
//
4157
// @Summary Get agent keyring metadata
4258
// @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.
@@ -61,8 +77,251 @@ func (h *KeyRingHandler) handleGet(w http.ResponseWriter, _ *http.Request) {
6177
Version: kr.Version,
6278
Entries: kr.Snapshot(),
6379
}
64-
w.Header().Set("Content-Type", "application/json")
65-
if err := json.NewEncoder(w).Encode(resp); err != nil {
66-
h.logger.Error("encode keyring response failed", "error", err)
80+
writeJSON(w, http.StatusOK, resp)
81+
}
82+
83+
// installRequest is the install endpoint's body. Secret is base64url-
84+
// encoded raw bytes (the same encoding used in the on-wire token).
85+
type installRequest struct {
86+
KID string `json:"kid"`
87+
SecretB64 string `json:"secret_b64"`
88+
Role string `json:"role"` // optional; defaults to "secondary"
89+
}
90+
91+
// handleInstall adds a new entry to the keyring. Idempotent: re-
92+
// installing the same (kid, secret) pair is a no-op and returns 200.
93+
// Installing a different secret under an existing kid returns 409.
94+
//
95+
// @Summary Install a new keyring entry
96+
// @Description Adds a new accepted API key to the agent's keyring. New entries default to role=secondary; the controller calls /use to flip primary after confirming installation.
97+
// @Tags system
98+
// @Security BearerAuth
99+
// @Accept json
100+
// @Produce json
101+
// @Param body body installRequest true "new key"
102+
// @Success 200 {object} keyRingResponse
103+
// @Failure 400 {string} string "Bad Request"
104+
// @Failure 401 {string} string "Unauthorized"
105+
// @Failure 409 {string} string "Conflict"
106+
// @Failure 507 {string} string "Keyring full"
107+
// @Router /api/v1/system/keyring/install [post]
108+
func (h *KeyRingHandler) handleInstall(w http.ResponseWriter, r *http.Request) {
109+
var req installRequest
110+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
111+
writeError(w, http.StatusBadRequest, "invalid JSON body")
112+
return
113+
}
114+
req.KID = strings.TrimSpace(req.KID)
115+
if req.KID == "" || req.SecretB64 == "" {
116+
writeError(w, http.StatusBadRequest, "kid and secret_b64 are required")
117+
return
118+
}
119+
secret, err := base64.RawURLEncoding.DecodeString(req.SecretB64)
120+
if err != nil || len(secret) != crypto.SecretLength {
121+
writeError(w, http.StatusBadRequest, "secret_b64 must be base64url-encoded 32 random bytes")
122+
return
123+
}
124+
role := req.Role
125+
if role == "" {
126+
role = "secondary"
127+
}
128+
if role != "primary" && role != "secondary" {
129+
writeError(w, http.StatusBadRequest, "role must be 'primary' or 'secondary'")
130+
return
131+
}
132+
133+
h.mu.Lock()
134+
defer h.mu.Unlock()
135+
136+
current := h.keyring.Load()
137+
next := current.Clone()
138+
139+
// Idempotency: same (kid, secret) → 200 with current state. Same
140+
// kid + different secret → 409. The controller treats both as
141+
// "already done" but the 409 surfaces a state divergence to logs.
142+
if existing := findEntry(current, req.KID); existing != nil {
143+
if constantTimeEq(existing.Secret, secret) {
144+
writeJSON(w, http.StatusOK, keyRingResponse{
145+
Version: current.Version,
146+
Entries: current.Snapshot(),
147+
})
148+
return
149+
}
150+
writeError(w, http.StatusConflict, "kid already exists with a different secret")
151+
return
152+
}
153+
154+
entry := crypto.KeyRingEntry{
155+
KID: req.KID,
156+
Secret: secret,
157+
Role: role,
158+
}
159+
if err := next.Add(entry); err != nil {
160+
if errors.Is(err, crypto.ErrKeyRingFull) {
161+
writeError(w, http.StatusInsufficientStorage, "keyring at maximum capacity")
162+
return
163+
}
164+
writeError(w, http.StatusBadRequest, "add entry: "+err.Error())
165+
return
166+
}
167+
168+
if err := crypto.SaveKeyRing(h.path, next); err != nil {
169+
h.logger.Error("save keyring", "error", err)
170+
writeError(w, http.StatusInternalServerError, "failed to persist keyring")
171+
return
172+
}
173+
h.keyring.Store(next)
174+
h.logger.Info("keyring: installed entry", "kid", req.KID, "role", role)
175+
176+
writeJSON(w, http.StatusOK, keyRingResponse{
177+
Version: next.Version,
178+
Entries: next.Snapshot(),
179+
})
180+
}
181+
182+
// useRequest is the use endpoint's body.
183+
type useRequest struct {
184+
KID string `json:"kid"`
185+
}
186+
187+
// handleUse flips the named entry to role=primary and demotes the
188+
// existing primary to secondary. Both stay accepted; the agent doesn't
189+
// distinguish primary vs secondary for inbound auth purposes, but the
190+
// /keyring metadata response and the kid echoed in `X-Gearbox-Kid` let
191+
// the controller drive the overlap window correctly.
192+
//
193+
// @Summary Promote a keyring entry to primary
194+
// @Description Flips the named keyring entry's role to "primary" and demotes the prior primary to "secondary". Both keys remain valid for inbound auth.
195+
// @Tags system
196+
// @Security BearerAuth
197+
// @Accept json
198+
// @Produce json
199+
// @Param body body useRequest true "target kid"
200+
// @Success 200 {object} keyRingResponse
201+
// @Failure 400 {string} string "Bad Request"
202+
// @Failure 401 {string} string "Unauthorized"
203+
// @Failure 404 {string} string "Unknown kid"
204+
// @Router /api/v1/system/keyring/use [post]
205+
func (h *KeyRingHandler) handleUse(w http.ResponseWriter, r *http.Request) {
206+
var req useRequest
207+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
208+
writeError(w, http.StatusBadRequest, "invalid JSON body")
209+
return
210+
}
211+
req.KID = strings.TrimSpace(req.KID)
212+
if req.KID == "" {
213+
writeError(w, http.StatusBadRequest, "kid is required")
214+
return
215+
}
216+
217+
h.mu.Lock()
218+
defer h.mu.Unlock()
219+
220+
next := h.keyring.Load().Clone()
221+
if err := next.SetPrimary(req.KID); err != nil {
222+
if errors.Is(err, crypto.ErrUnknownKID) {
223+
writeError(w, http.StatusNotFound, "unknown kid")
224+
return
225+
}
226+
writeError(w, http.StatusInternalServerError, "set primary: "+err.Error())
227+
return
228+
}
229+
230+
if err := crypto.SaveKeyRing(h.path, next); err != nil {
231+
h.logger.Error("save keyring", "error", err)
232+
writeError(w, http.StatusInternalServerError, "failed to persist keyring")
233+
return
234+
}
235+
h.keyring.Store(next)
236+
h.logger.Info("keyring: promoted to primary", "kid", req.KID)
237+
238+
writeJSON(w, http.StatusOK, keyRingResponse{
239+
Version: next.Version,
240+
Entries: next.Snapshot(),
241+
})
242+
}
243+
244+
// handleRemove deletes the named entry. Refuses to remove the only
245+
// remaining entry — an operator error otherwise bricks the box.
246+
//
247+
// @Summary Remove a keyring entry
248+
// @Description Deletes the named keyring entry. Refuses to remove the only remaining entry; the agent always retains at least one accepted key.
249+
// @Tags system
250+
// @Security BearerAuth
251+
// @Param kid path string true "key id"
252+
// @Success 200 {object} keyRingResponse
253+
// @Failure 401 {string} string "Unauthorized"
254+
// @Failure 404 {string} string "Unknown kid"
255+
// @Failure 409 {string} string "Cannot remove last key"
256+
// @Router /api/v1/system/keyring/{kid} [delete]
257+
func (h *KeyRingHandler) handleRemove(w http.ResponseWriter, r *http.Request) {
258+
kid := strings.TrimSpace(chi.URLParam(r, "kid"))
259+
if kid == "" {
260+
writeError(w, http.StatusBadRequest, "kid is required")
261+
return
67262
}
263+
264+
h.mu.Lock()
265+
defer h.mu.Unlock()
266+
267+
next := h.keyring.Load().Clone()
268+
if err := next.Remove(kid); err != nil {
269+
switch {
270+
case errors.Is(err, crypto.ErrUnknownKID):
271+
writeError(w, http.StatusNotFound, "unknown kid")
272+
case errors.Is(err, crypto.ErrCannotRemoveLast):
273+
writeError(w, http.StatusConflict, "cannot remove the only remaining key")
274+
default:
275+
writeError(w, http.StatusInternalServerError, "remove: "+err.Error())
276+
}
277+
return
278+
}
279+
280+
if err := crypto.SaveKeyRing(h.path, next); err != nil {
281+
h.logger.Error("save keyring", "error", err)
282+
writeError(w, http.StatusInternalServerError, "failed to persist keyring")
283+
return
284+
}
285+
h.keyring.Store(next)
286+
h.logger.Info("keyring: removed entry", "kid", kid)
287+
288+
writeJSON(w, http.StatusOK, keyRingResponse{
289+
Version: next.Version,
290+
Entries: next.Snapshot(),
291+
})
292+
}
293+
294+
// findEntry returns the entry with the given kid, or nil. Reads only
295+
// — does not lock; caller already holds the handler mutex.
296+
func findEntry(kr *crypto.KeyRing, kid string) *crypto.KeyRingEntry {
297+
for i := range kr.Entries {
298+
if kr.Entries[i].KID == kid {
299+
return &kr.Entries[i]
300+
}
301+
}
302+
return nil
303+
}
304+
305+
// constantTimeEq compares two byte slices in constant time. Used by
306+
// the install-idempotency check so reinstalling under an existing kid
307+
// doesn't leak timing about the existing secret.
308+
func constantTimeEq(a, b []byte) bool {
309+
if len(a) != len(b) {
310+
return false
311+
}
312+
var v byte
313+
for i := range a {
314+
v |= a[i] ^ b[i]
315+
}
316+
return v == 0
317+
}
318+
319+
func writeJSON(w http.ResponseWriter, status int, payload any) {
320+
w.Header().Set("Content-Type", "application/json")
321+
w.WriteHeader(status)
322+
_ = json.NewEncoder(w).Encode(payload)
323+
}
324+
325+
func writeError(w http.ResponseWriter, status int, msg string) {
326+
writeJSON(w, status, map[string]string{"error": msg})
68327
}

0 commit comments

Comments
 (0)