Skip to content

feat: keep an accepted agent's public key after enrollment - #516

Merged
retr0h merged 2 commits into
mainfrom
feat/agent-key-store
Sep 18, 2026
Merged

retr0h merged 2 commits into
mainfrom
feat/agent-key-store

Conversation

@retr0h

@retr0h retr0h commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Foundational phase (T001-T013) of components/osapi/specs/002-agent-key-store.
US1 and US2 build on this and close GHSA-3jh4's deferred half and GHSA-j73r
respectively.

What this adds

  • AcceptedAgent record and an accepted.<machine-id> prefix in the existing
    enrollment bucket — no new bucket, config field, or provisioning step.
  • Record / Lookup / Remove in internal/controller/enrollment/keystore.go,
    with a per-machine-ID cache invalidated on write and removal rather than by
    time, since a TTL would leave a removed agent verifying until it expired.
  • Three distinct sentinels — ErrAgentKeyNotFound,
    ErrAgentKeyStoreUnavailable, ErrAgentKeySignatureMismatch — mirroring the
    agent-side taxonomy added for GHSA-3jh4. A missing record and an unreadable
    store are never the same answer.
  • Acceptance is the only writer; rejection removes the record.
  • AgentKeyStore, the narrow lookup the job client and target resolution will
    depend on, so neither imports this package wholesale.

A bug found while implementing

ListPending and findPendingBy iterated every key in the bucket and
unmarshalled each as a PendingAgent. That worked only because every key
happened to share the enrollment. prefix. An accepted.* record unmarshals
into PendingAgent cleanly — machine_id, hostname, public_key and
fingerprint all overlap — so it would have appeared as a pending agent that
could then be "accepted" again. Both scans now filter on the pending prefix,
and a test in each proves the skip by expecting no Get for an accepted.*
key.

Two deviations from the task list

  • T004 put the new prefix in types.go; the existing kvPrefix is actually
    in watcher.go, so both now sit together there, which is what the task
    intended.
  • T010 (threading the store through cmd/controller_setup.go) is deferred to
    US1. The Watcher is the store, and no consumer exists yet — wiring it now
    would add an unused parameter.

pki.FingerprintOf was extracted from Manager.Fingerprint so the fingerprint
stored at acceptance is recomputed from the key rather than copied from the
self-reported request, with one digest format in the codebase.

Gate

just react-build && just ready && just test green: coverage 100.0%
against the 99.9% target.

The new suite is deliberately not t.Parallel(). This package swaps
package-level marshal/now seams, and Go runs parallel tests only alongside other
parallel tests — so the rotation suite was safe as the only one. Adding a second
produced a real data race, caught by -race and confirmed with -count=5.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c

The controller had nowhere to keep an agent's public key: it lived only on
the pending-enrollment record, which acceptance deletes. Every
controller-side verification path therefore found no key and skipped,
which is why the agent's response signing is currently decorative.

Store the key in the enrollment bucket under an accepted.<machine-id>
prefix, written only by acceptance and removed on rejection, with a
per-machine-ID cache invalidated by both. A missing record, an unreadable
store and a signature mismatch are three distinct sentinels, so an
operator can tell "not enrolled yet" from "something is forging
messages".

Both scans of the bucket unmarshalled whatever keys they found as pending
agents, which worked only because every key shared one prefix. An
accepted record's JSON overlaps PendingAgent exactly, so it would have
surfaced as a phantom pending agent; both scans now filter on the pending
prefix.

Verification itself lands next, on top of this store.

Foundational phase of specs 002-agent-key-store. No behaviour changes
while PKI is disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c
@codecov

codecov Bot commented Sep 18, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Impacted file tree graph

@@           Coverage Diff           @@
##             main     #516   +/-   ##
=======================================
  Coverage   99.95%   99.95%           
=======================================
  Files         489      490    +1     
  Lines       23180    23249   +69     
=======================================
+ Hits        23170    23239   +69     
  Misses         10       10           
Files with missing lines Coverage Δ
internal/agent/pki/keypair.go 100.00% <100.00%> (ø)
internal/controller/enrollment/accept.go 100.00% <100.00%> (ø)
internal/controller/enrollment/keystore.go 100.00% <100.00%> (ø)
internal/controller/enrollment/watcher.go 100.00% <100.00%> (ø)

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 1fbd04d...fcebe14. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The canceled-context case closed the stop channel immediately after
starting the keepalive goroutine, so the goroutine often reached its
select with both stop and ctx.Done() already ready. Select picks
uniformly at random among ready cases, so the ctx.Done() return ran only
about half the time.

That made coverage of that line flap between runs: it shows 0, 1 or 2
misses across recent commits on main with the file unchanged. Measured
here at 4 of 10 runs missing it before this change, 0 of 10 after.

The other three cases already slept before stopping; this drops the
condition so every case does, which lets the goroutine reach its select
while a canceled context is the only ready case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FuKUsHFG1EqZXamffh9M2c
@retr0h
retr0h merged commit 86e7852 into main Sep 18, 2026
12 checks passed
@retr0h
retr0h deleted the feat/agent-key-store branch September 18, 2026 23:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant