You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
0 commit comments