Skip to content

Commit 9326566

Browse files
sarg3ntclaude
andcommitted
chore(rotation): address Copilot review on PR #128
Nine findings from the Copilot review on PR #128, all valid or worth addressing. Fixed in this commit; replies + thread-resolves go with the push. 1. LoadOrCreateKeyRing fall-through (keyring.go:131-167) Was: any error reading the legacy api-key file (incl. ErrKeyRequired from a missing encryption-key env, or a permission error) silently fell through to generating a fresh keyring — would rotate every dashboard out for a transient operator mistake. Now: distinguish "file doesn't exist" (proceed to fresh-gen) from "file exists but errored / malformed" (return the error to the caller). os.Stat + os.IsNotExist gates the choice explicitly. 2. MatchToken constant-time guarantee (keyring.go ~195) Was: the prefixed-token path returned early on the first kid match, making total runtime depend on which kid the request claimed — kid enumeration via timing. The doc said "All comparisons are constant-time" but the prefixed branch broke that promise. Now: walk every entry, compare both kid and secret with subtle.ConstantTimeCompare, AND the two results. Match is recorded without short-circuit; runtime is uniform regardless of which kid (if any) matches. Doc updated to reflect the actual guarantee. 3. writeKeyRingFile mutates input (keyring.go ~415) Was: the function populated SecretHex on each entry of the passed- in keyring before marshaling. KeyRing values are shared via atomic.Pointer and treated as immutable; mutating in-place risks races with concurrent middleware readers. Now: marshal off a local snapshot whose entries have SecretHex backfilled from Secret where needed. Input is never written to. 4. --rotate-api-key zero CreatedAt (main.go ~155) Was: the fresh KeyRingEntry built for the CLI rotate command omitted CreatedAt, so the keyring file got 0001-01-01T00:00:00Z and the /api/v1/system/keyring metadata exposed the same. Now: CreatedAt: time.Now().UTC(). 5. handleGet nil-guard (api/keyring.go ~50) Was: h.keyring.Load() was dereferenced unconditionally; a future wiring bug that left the pointer nil would panic the agent on every keyring request. Now: nil check + 500 + log line. Fails loud rather than crashing. 6. At-most-one-primary-per-box constraint (migration 000002) Was: nothing in the schema stopped two rows with role='primary' for the same box. SetBoxPrimaryKey's transactional flip is correct, but a buggy code path or a manual DB edit could produce the invalid state and GetBoxPrimaryKey would return an arbitrary row. Now: partial unique index on box_agent_keys(box_id) WHERE role='primary'. SQLite supports this directly; index is dropped in the down migration too. 7. Test naming clarity (box_agent_keys_test.go) Was: TestBoxAgentKeys_MigrationBackfillsLegacyEntry was named as if it validated migration behaviour but actually only exercised InsertBoxAgentKey + GetBoxPrimaryKey roundtrip; the comment also misled. Now: split into two clearly-named tests — InsertAndLookup covers the roundtrip, and a new MigrationBackfillStatementWorks test wipes the migrated rows for a single box, re-executes the migration's INSERT-FROM-boxes statement, and asserts the row appears + reruns are idempotent. 8. DeleteBox cascade gap (servers.go DeleteBox) Was: the schema declared ON DELETE CASCADE but PRAGMA foreign_keys is off in this codebase, so deleting a box left orphaned box_agent_keys rows holding encrypted secrets. Phase 1 docs flagged this as a deferred gap; Copilot pushed back, and fairly — it's a small, contained fix. Now: DeleteBox runs inside a transaction that wipes box_agent_keys WHERE box_id = ? before deleting from boxes. Both succeed or neither does. Test re-added: TestBoxAgentKeys_DeleteBoxClearsDependentKeys. 9. APIKeyAuth nil-guard (middleware/auth.go) Was: keyring.Load() was called without first checking the pointer itself for nil. A miswired ServerConfig would panic on every authenticated request. Now: fail-closed nil check at the top of the request handler — returns 401 + logs at error level. Same defensive treatment as fix #5. Tests ----- All 3 dashboard-side suites pass (`database` package, 7 new tests including the new DeleteBox cascade test). All 3 agent-side suites pass (`crypto`, `middleware`, `api`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 53ad122 commit 9326566

8 files changed

Lines changed: 211 additions & 53 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,10 @@ func main() {
159159
os.Exit(1)
160160
}
161161
fresh := crypto.KeyRingEntry{
162-
KID: kid,
163-
Secret: secret,
164-
Role: "primary",
162+
KID: kid,
163+
Secret: secret,
164+
Role: "primary",
165+
CreatedAt: time.Now().UTC(),
165166
}
166167
kr.Entries = []crypto.KeyRingEntry{fresh}
167168
if err := crypto.SaveKeyRing(cfg.KeyRingPath, kr); err != nil {

gearbox-agent/internal/api/keyring.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ type keyRingResponse struct {
4848
// @Router /api/v1/system/keyring [get]
4949
func (h *KeyRingHandler) handleGet(w http.ResponseWriter, _ *http.Request) {
5050
kr := h.keyring.Load()
51+
if kr == nil {
52+
// Should be unreachable — main.go calls NewKeyRingPointer(kr)
53+
// with a non-nil value before mounting the handler. Defensive
54+
// 500 + log so a future wiring bug fails loud rather than
55+
// panicking the agent on every keyring request.
56+
h.logger.Error("keyring pointer empty when serving /system/keyring")
57+
http.Error(w, "keyring unavailable", http.StatusInternalServerError)
58+
return
59+
}
5160
resp := keyRingResponse{
5261
Version: kr.Version,
5362
Entries: kr.Snapshot(),

gearbox-agent/internal/framework/crypto/keyring.go

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -109,25 +109,49 @@ func LoadOrCreateKeyRing(path, legacyAPIKeyPath string) (*KeyRing, bool, error)
109109
}
110110

111111
// No keyring file. Migrate from legacy api_key file if present.
112+
//
113+
// Failure modes we distinguish:
114+
// - legacy file doesn't exist → fall through to fresh-gen
115+
// - legacy file exists but errors → fatal (caller may have set
116+
// GEARBOX_AGENT_ENCRYPTION_KEY
117+
// wrong; silently generating
118+
// a fresh keyring would lock
119+
// the dashboard out for no
120+
// good reason)
121+
// - legacy file exists but is malformed (not 64 hex chars) →
122+
// fatal for the same reason
123+
// (a wedged file is operator-
124+
// visible; a silent rotation
125+
// is not).
112126
if legacyAPIKeyPath != "" {
113-
if legacyKey, err := ReadAPIKey(legacyAPIKeyPath); err == nil {
127+
if _, statErr := os.Stat(legacyAPIKeyPath); statErr == nil {
128+
legacyKey, rerr := ReadAPIKey(legacyAPIKeyPath)
129+
if rerr != nil {
130+
return nil, false, fmt.Errorf("read legacy api-key file %q: %w", legacyAPIKeyPath, rerr)
131+
}
114132
secret, derr := hex.DecodeString(strings.TrimSpace(legacyKey))
115-
if derr == nil && len(secret) == SecretLength {
116-
kr := &KeyRing{
117-
Version: 1,
118-
Entries: []KeyRingEntry{{
119-
KID: "legacy",
120-
Secret: secret,
121-
SecretHex: hex.EncodeToString(secret),
122-
Role: "primary",
123-
CreatedAt: time.Now().UTC(),
124-
}},
125-
}
126-
if werr := writeKeyRingFile(path, kr); werr != nil {
127-
return nil, false, fmt.Errorf("write migrated keyring: %w", werr)
128-
}
129-
return kr, false, nil
133+
if derr != nil {
134+
return nil, false, fmt.Errorf("legacy api-key file %q is not valid hex: %w", legacyAPIKeyPath, derr)
135+
}
136+
if len(secret) != SecretLength {
137+
return nil, false, fmt.Errorf("legacy api-key file %q has secret length %d, want %d", legacyAPIKeyPath, len(secret), SecretLength)
138+
}
139+
kr := &KeyRing{
140+
Version: 1,
141+
Entries: []KeyRingEntry{{
142+
KID: "legacy",
143+
Secret: secret,
144+
SecretHex: hex.EncodeToString(secret),
145+
Role: "primary",
146+
CreatedAt: time.Now().UTC(),
147+
}},
130148
}
149+
if werr := writeKeyRingFile(path, kr); werr != nil {
150+
return nil, false, fmt.Errorf("write migrated keyring: %w", werr)
151+
}
152+
return kr, false, nil
153+
} else if !os.IsNotExist(statErr) {
154+
return nil, false, fmt.Errorf("stat legacy api-key file %q: %w", legacyAPIKeyPath, statErr)
131155
}
132156
}
133157

@@ -180,7 +204,14 @@ func (kr *KeyRing) Primary() *KeyRingEntry {
180204
// 64-hex) and returns the matching entry, or nil + ErrUnknownKID / nil +
181205
// ErrInvalidToken on failure.
182206
//
183-
// All comparisons are constant-time.
207+
// Comparisons against entry secrets are constant-time (subtle.
208+
// ConstantTimeCompare). The prefixed-token path walks every entry and
209+
// performs the compare on each one regardless of whether the kid
210+
// matched, so total runtime doesn't reveal which kids exist on this
211+
// agent — kids are not strictly secret (they're sent in the request
212+
// header), but leaking which ones an agent currently accepts via
213+
// timing makes rotation-history enumeration cheap, which we'd rather
214+
// not.
184215
func (kr *KeyRing) MatchToken(token string) (*KeyRingEntry, error) {
185216
if strings.HasPrefix(token, tokenPrefix) {
186217
// Prefixed: gbx_<kid>_<b64secret>
@@ -195,18 +226,22 @@ func (kr *KeyRing) MatchToken(token string) (*KeyRingEntry, error) {
195226
if err != nil || len(secret) != SecretLength {
196227
return nil, ErrInvalidToken
197228
}
229+
// Walk every entry, compare every secret, record the match
230+
// without short-circuiting. The two ConstantTimeEq calls plus
231+
// the bitwise AND keep the timing uniform regardless of which
232+
// entry (if any) matches.
233+
var matched *KeyRingEntry
198234
for i := range kr.Entries {
199-
if kr.Entries[i].KID != kid {
200-
continue
201-
}
202-
if subtle.ConstantTimeCompare(kr.Entries[i].Secret, secret) == 1 {
203-
return &kr.Entries[i], nil
235+
kidEq := subtle.ConstantTimeCompare([]byte(kr.Entries[i].KID), []byte(kid))
236+
secretEq := subtle.ConstantTimeCompare(kr.Entries[i].Secret, secret)
237+
if kidEq&secretEq == 1 {
238+
matched = &kr.Entries[i]
204239
}
205-
// Same kid but secret doesn't match — bail without trying other
206-
// entries; the caller's kid claim is wrong.
240+
}
241+
if matched == nil {
207242
return nil, ErrUnknownKID
208243
}
209-
return nil, ErrUnknownKID
244+
return matched, nil
210245
}
211246

212247
// Legacy: 64 hex chars, brute-compare against every entry's secret.
@@ -410,15 +445,22 @@ func hydrateSecrets(kr *KeyRing) error {
410445

411446
// writeKeyRingFile serializes kr to JSON and atomically writes to path,
412447
// encrypting with the default KeyProvider when configured.
448+
//
449+
// Does NOT mutate the input keyring. KeyRing values are shared via
450+
// [*atomic.Pointer] and treated as immutable; populating SecretHex
451+
// directly on the live entries would race with concurrent readers in
452+
// the auth middleware. We marshal off a local snapshot whose entries
453+
// have SecretHex backfilled from Secret where needed.
413454
func writeKeyRingFile(path string, kr *KeyRing) error {
414-
// Ensure every entry has SecretHex populated for serialization.
415-
for i := range kr.Entries {
416-
if kr.Entries[i].SecretHex == "" {
417-
kr.Entries[i].SecretHex = hex.EncodeToString(kr.Entries[i].Secret)
455+
snapshot := &KeyRing{Version: kr.Version, Entries: make([]KeyRingEntry, len(kr.Entries))}
456+
for i, e := range kr.Entries {
457+
snapshot.Entries[i] = e
458+
if snapshot.Entries[i].SecretHex == "" {
459+
snapshot.Entries[i].SecretHex = hex.EncodeToString(e.Secret)
418460
}
419461
}
420462

421-
plaintext, err := json.MarshalIndent(kr, "", " ")
463+
plaintext, err := json.MarshalIndent(snapshot, "", " ")
422464
if err != nil {
423465
return fmt.Errorf("marshal keyring: %w", err)
424466
}

gearbox-agent/internal/framework/middleware/auth.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,17 @@ const ResponseHeaderKID = "X-Gearbox-Kid"
3434
func APIKeyAuth(keyring *crypto.KeyRingPointer, logger *slog.Logger, backoff *BackoffTracker) func(http.Handler) http.Handler {
3535
return func(next http.Handler) http.Handler {
3636
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+
// Fail-closed nil guard. Should be unreachable — the
38+
// agent's main.go always passes a non-nil keyring pointer
39+
// before this middleware is mounted — but a future wiring
40+
// bug should 401 every request instead of panicking the
41+
// agent on the auth path.
42+
if keyring == nil {
43+
logger.Error("AUTH DENIED: middleware constructed with nil keyring pointer", "remote_addr", r.RemoteAddr)
44+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
45+
return
46+
}
47+
3748
ip := clientIPFromRemoteAddr(r.RemoteAddr)
3849

3950
if backoff != nil && backoff.IsBlocked(ip) {

gearbox/internal/framework/database/box_agent_keys_test.go

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ func newTestBox(t *testing.T, db *DB, boxIDStr, name string) *BoxDB {
1919
return box
2020
}
2121

22-
func TestBoxAgentKeys_MigrationBackfillsLegacyEntry(t *testing.T) {
22+
// TestBoxAgentKeys_InsertAndLookup exercises the Insert/Get round-
23+
// trip — what the rotator (Phase 2) and the per-box rotate handler
24+
// (Phase 3) rely on for a freshly-created box.
25+
//
26+
// This does NOT exercise the migration's backfill INSERT-FROM-boxes
27+
// path; see TestBoxAgentKeys_MigrationBackfillStatementWorks below
28+
// for that.
29+
func TestBoxAgentKeys_InsertAndLookup(t *testing.T) {
2330
db := setupTestDB(t)
24-
// Insert a box BEFORE we look at the keyring; the schema-init path
25-
// runs the box_agent_keys backfill on every startup, but for a row
26-
// inserted afterwards the backfill won't fire. Phase 2's create-box
27-
// handler is responsible for seating the legacy entry on NEW boxes;
28-
// for now, verify the rotation-table is reachable end-to-end via the
29-
// direct insert path.
3031
box := newTestBox(t, db, "box-a", "Box A")
3132

32-
// Direct insert simulates what Phase 2's create-box path will do.
3333
if err := db.InsertBoxAgentKey(&BoxAgentKey{
3434
BoxID: box.ID,
3535
KID: "legacy",
@@ -48,6 +48,53 @@ func TestBoxAgentKeys_MigrationBackfillsLegacyEntry(t *testing.T) {
4848
}
4949
}
5050

51+
// TestBoxAgentKeys_MigrationBackfillStatementWorks exercises the
52+
// idempotent INSERT-FROM-boxes statement from migration 000002. We
53+
// can't easily replay the migration on a per-test DB (it only runs
54+
// once), so this test wipes the migrated rows for a single box, then
55+
// re-executes the same backfill SQL against the live DB and asserts
56+
// the expected rows appear. Catches regressions to the SQL itself.
57+
func TestBoxAgentKeys_MigrationBackfillStatementWorks(t *testing.T) {
58+
db := setupTestDB(t)
59+
box := newTestBox(t, db, "box-mig", "Migration Box")
60+
61+
rawDB := db.GetDB()
62+
if _, err := rawDB.Exec(`DELETE FROM box_agent_keys WHERE box_id = ?`, box.ID); err != nil {
63+
t.Fatalf("wipe: %v", err)
64+
}
65+
66+
const backfillSQL = `
67+
INSERT INTO box_agent_keys (box_id, kid, secret_encrypted, role, created_at)
68+
SELECT id, 'legacy', api_key_encrypted, 'primary', created_at
69+
FROM boxes
70+
WHERE id = ? AND NOT EXISTS (
71+
SELECT 1 FROM box_agent_keys WHERE box_id = boxes.id AND kid = 'legacy'
72+
)`
73+
if _, err := rawDB.Exec(backfillSQL, box.ID); err != nil {
74+
t.Fatalf("backfill: %v", err)
75+
}
76+
77+
primary, err := db.GetBoxPrimaryKey(box.ID)
78+
if err != nil {
79+
t.Fatalf("GetBoxPrimaryKey: %v", err)
80+
}
81+
if primary == nil || primary.KID != "legacy" {
82+
t.Fatalf("backfill: got %+v, want kid=legacy primary", primary)
83+
}
84+
85+
// Re-running the backfill must be a no-op (idempotency guard).
86+
if _, err := rawDB.Exec(backfillSQL, box.ID); err != nil {
87+
t.Fatalf("backfill rerun: %v", err)
88+
}
89+
keys, err := db.GetBoxAgentKeys(box.ID)
90+
if err != nil {
91+
t.Fatalf("GetBoxAgentKeys: %v", err)
92+
}
93+
if len(keys) != 1 {
94+
t.Errorf("backfill rerun was not idempotent: %d rows, want 1", len(keys))
95+
}
96+
}
97+
5198
func TestBoxAgentKeys_SetPrimary_FlipsRolesAtomically(t *testing.T) {
5299
db := setupTestDB(t)
53100
box := newTestBox(t, db, "box-b", "Box B")
@@ -143,11 +190,34 @@ func TestBoxAgentKeys_TouchLastUsed(t *testing.T) {
143190
}
144191
}
145192

146-
// Cascade-on-DeleteBox is currently declared in the schema (FOREIGN KEY
147-
// (box_id) REFERENCES boxes(id) ON DELETE CASCADE) but not enforced —
148-
// the gearbox DB doesn't set `PRAGMA foreign_keys = ON`. Enabling that
149-
// pragma is a broader change that risks regressing on legacy rows
150-
// elsewhere in the schema. For now DeleteBox leaves orphan
151-
// box_agent_keys rows on disk; Phase 2's box-delete path will clean
152-
// them up explicitly. Not tested here so the gap is visible from the
153-
// test surface itself.
193+
// TestBoxAgentKeys_DeleteBoxClearsDependentKeys verifies that
194+
// DeleteBox removes rows from box_agent_keys for that box, even
195+
// though SQLite's PRAGMA foreign_keys is off in this codebase (so
196+
// the schema-declared CASCADE doesn't run). DeleteBox now wipes
197+
// dependents inside its transaction, which is what this test pins.
198+
func TestBoxAgentKeys_DeleteBoxClearsDependentKeys(t *testing.T) {
199+
db := setupTestDB(t)
200+
box := newTestBox(t, db, "box-f", "Box F")
201+
if err := db.InsertBoxAgentKey(&BoxAgentKey{
202+
BoxID: box.ID, KID: "legacy", SecretEncrypted: []byte("e"), Role: "primary",
203+
}); err != nil {
204+
t.Fatalf("insert: %v", err)
205+
}
206+
if err := db.InsertBoxAgentKey(&BoxAgentKey{
207+
BoxID: box.ID, KID: "v2", SecretEncrypted: []byte("e2"), Role: "secondary",
208+
}); err != nil {
209+
t.Fatalf("insert v2: %v", err)
210+
}
211+
212+
if err := db.DeleteBox(box.ID); err != nil {
213+
t.Fatalf("DeleteBox: %v", err)
214+
}
215+
216+
keys, err := db.GetBoxAgentKeys(box.ID)
217+
if err != nil {
218+
t.Fatalf("GetBoxAgentKeys: %v", err)
219+
}
220+
if len(keys) != 0 {
221+
t.Errorf("keys not removed on box delete: %+v", keys)
222+
}
223+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
DROP INDEX IF EXISTS idx_box_agent_keys_one_primary;
12
DROP INDEX IF EXISTS idx_box_agent_keys_role;
23
DROP INDEX IF EXISTS idx_box_agent_keys_box;
34
DROP TABLE IF EXISTS box_agent_keys;

gearbox/internal/framework/database/migrations/files/000002_add_box_agent_keys.up.sql

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ CREATE INDEX IF NOT EXISTS idx_box_agent_keys_box
3434
CREATE INDEX IF NOT EXISTS idx_box_agent_keys_role
3535
ON box_agent_keys(box_id, role);
3636

37+
-- Enforce the "at most one primary per box" invariant the rotator
38+
-- relies on. Without this constraint a buggy SetBoxPrimaryKey path
39+
-- could leave two primaries on the same box and GetBoxPrimaryKey
40+
-- would return an arbitrary one. Partial-unique-index is the
41+
-- SQLite-supported way to express "unique only when role='primary'".
42+
CREATE UNIQUE INDEX IF NOT EXISTS idx_box_agent_keys_one_primary
43+
ON box_agent_keys(box_id) WHERE role = 'primary';
44+
3745
-- Backfill: every existing box gets a legacy entry that's a copy of its
3846
-- current `api_key_encrypted` value, marked primary. Idempotent — the
3947
-- WHERE NOT EXISTS guard means re-running the migration on a partially-

gearbox/internal/framework/database/servers.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,13 +297,29 @@ func (d *DB) UpdateBox(box *BoxDB) error {
297297
return nil
298298
}
299299

300-
// DeleteBox deletes a box configuration from the database.
300+
// DeleteBox deletes a box configuration from the database, along with
301+
// any rotation-keyring entries that reference it.
302+
//
303+
// The schema declares ON DELETE CASCADE on box_agent_keys.box_id, but
304+
// this codebase doesn't set PRAGMA foreign_keys=ON (enabling it is a
305+
// broader change that risks regressing on legacy rows elsewhere). To
306+
// avoid leaving orphaned rows that hold encrypted secrets, the
307+
// dependent table is wiped explicitly inside the same transaction.
301308
func (d *DB) DeleteBox(id int64) error {
302309
d.mu.Lock()
303310
defer d.mu.Unlock()
304311

305-
query := `DELETE FROM boxes WHERE id = ?`
306-
result, err := d.db.Exec(query, id)
312+
tx, err := d.db.Begin()
313+
if err != nil {
314+
return fmt.Errorf("begin tx: %w", err)
315+
}
316+
defer func() { _ = tx.Rollback() }()
317+
318+
if _, err := tx.Exec(`DELETE FROM box_agent_keys WHERE box_id = ?`, id); err != nil {
319+
return fmt.Errorf("delete dependent keys: %w", err)
320+
}
321+
322+
result, err := tx.Exec(`DELETE FROM boxes WHERE id = ?`, id)
307323
if err != nil {
308324
return fmt.Errorf("failed to delete box: %w", err)
309325
}
@@ -316,7 +332,7 @@ func (d *DB) DeleteBox(id int64) error {
316332
return fmt.Errorf("box not found")
317333
}
318334

319-
return nil
335+
return tx.Commit()
320336
}
321337

322338
// SetBoxEnabled enables or disables a box.

0 commit comments

Comments
 (0)