Skip to content

pilotctl: anchor the cap-state HMAC chain to its length and tail - #429

Merged
TeoSlayer merged 1 commit into
mainfrom
sec/findings-web4
Jul 26, 2026
Merged

pilotctl: anchor the cap-state HMAC chain to its length and tail#429
TeoSlayer merged 1 commit into
mainfrom
sec/findings-web4

Conversation

@TeoSlayer

Copy link
Copy Markdown
Collaborator

L5 — cap-state HMAC chain has no tail anchor

loadCapStateRecords (cmd/pilotctl/appstore.go) verifies a chained HMAC per line, where each record's MAC covers the record plus the previous record's MAC. That commits to everything before a record but to nothing after it, so every prefix of a valid chain is itself a valid chain. A cap-state.jsonl rewound to an earlier prefix verified line by line and reported less usage — despite the existing error string already claiming to detect "truncated" logs.

Fix

A sidecar anchor file cap-state.jsonl.anchor holds the number of authenticated records and the final chain value, bound together by a MAC under the same HKDF-derived key the chain uses (so it cannot be rewritten without the daemon identity).

loadCapStateRecordsAnchored wraps the existing loader and checks the anchor:

  • fewer authenticated records than the anchor recorded → error;
  • a different chain value at the anchored position → error;
  • a strictly longer chain that still matches at the anchored position → anchor advances;
  • an unparseable / wrong-version / bad-MAC anchor → error.

pilotctl appstore caps now goes through the anchored loader.

Backward compatibility

  • A log with no anchor beside it loads normally; the anchor is adopted and written for it.
  • Wholly unauthenticated (legacy, no hmac field) logs skip the anchor path entirely.
  • No key available → anchor logic is skipped.
  • Anchor writes are best-effort, so a read-only install tree degrades to the previous behaviour instead of failing the command.
  • Strict rejection of an anchorless authenticated chain is available behind PILOT_CAP_STATE_STRICT_ANCHOR=1, default off.

No wire or protocol change — this is a local on-disk sidecar read/written only by pilotctl.

Tests

TestLoadCapStateRecordsAnchoredDetectsTruncation builds a 4-record chain, adopts an anchor at 3, appends to 4, then rewinds to 2 — asserting the prefix still passes per-record verification (loadCapStateRecords) while the anchored loader rejects it. Also covers an emptied log and a forged anchor. TestLoadCapStateRecordsAnchoredLegacyStillLoads pins the legacy/missing-file/nil-key paths, and TestLoadCapStateRecordsStrictAnchorFlag covers the default-off flag.

go build ./cmd/pilotctl/..., go vet, and go test ./cmd/pilotctl/... all pass.

🤖 Generated with Claude Code

The per-record chained HMAC over cap-state.jsonl commits only to the
records preceding each record, so every prefix of a valid chain is
itself a valid chain. A log rewound to an earlier prefix verified line
by line and simply reported less usage.

Add a sidecar anchor file (cap-state.jsonl.anchor) holding the number of
authenticated records and the final chain value, bound together by a MAC
under the same key the chain uses. loadCapStateRecordsAnchored checks it
on load: fewer records than anchored, or a different chain value at the
anchored position, is an error; a strictly longer chain that still
matches advances the anchor.

Backward compatible: a log with no anchor beside it is accepted and an
anchor is adopted for it, and unauthenticated (legacy) logs skip the
anchor entirely. Setting PILOT_CAP_STATE_STRICT_ANCHOR=1 (default unset)
turns the anchorless case into an error instead. Anchor writes are
best-effort so a read-only install tree keeps working.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@TeoSlayer
TeoSlayer merged commit acec2cc into main Jul 26, 2026
15 checks passed
@TeoSlayer
TeoSlayer deleted the sec/findings-web4 branch July 26, 2026 14:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants