Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 173 additions & 1 deletion cmd/pilotctl/appstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -1644,7 +1644,7 @@ func cmdAppStoreCaps(args []string) {
// Derive HMAC key from daemon identity for cap-state integrity.
// Falls back to nil (no verification) if identity is unavailable.
hmacKey := loadCapStateHMACKey()
records, err := loadCapStateRecords(filepath.Join(appDir, "cap-state.jsonl"), hmacKey)
records, err := loadCapStateRecordsAnchored(filepath.Join(appDir, "cap-state.jsonl"), hmacKey)
if err != nil {
// Fail closed: a tampered or corrupt spend log must surface as
// an error, not be papered over with an under-reported usage
Expand Down Expand Up @@ -1879,11 +1879,14 @@ type capStateJSONNoHMAC struct {

// capStateRecord is the local pilotctl-side projection of one
// cap-state.jsonl line. hmacOK is true when HMAC verified or absent.
// chain holds this record's decoded chained HMAC, or nil when the
// record carries no HMAC (or no key was supplied).
type capStateRecord struct {
at time.Time
asset string
amount uint64
hmacOK bool
chain []byte
}

// loadCapStateRecords reads the wallet's persistent spend log, failing
Expand Down Expand Up @@ -1957,6 +1960,7 @@ func loadCapStateRecords(path string, hmacKey []byte) ([]capStateRecord, error)
}
rec.hmacOK = true
prevHMAC, _ = base64.StdEncoding.DecodeString(line.HMAC)
rec.chain = prevHMAC
}

out = append(out, rec)
Expand All @@ -1972,6 +1976,174 @@ func loadCapStateRecords(path string, hmacKey []byte) ([]capStateRecord, error)
return out, nil
}

// capStateAnchorSuffix is appended to the cap-state path to name the
// sidecar anchor file that sits beside it.
const capStateAnchorSuffix = ".anchor"

// capStateAnchorEnv, when set to a truthy value, makes a missing anchor
// beside an authenticated chain an error instead of something to adopt.
// Default (unset) is adopt-on-first-sight.
const capStateAnchorEnv = "PILOT_CAP_STATE_STRICT_ANCHOR"

// capStateAnchor is the sidecar record of how far the authenticated
// cap-state chain had advanced the last time it was read: how many
// chained records there were and the chain value of the last of them.
// MAC binds those two fields together under the same key the chain
// itself uses, so the anchor cannot be rewritten without the key.
type capStateAnchor struct {
Version int `json:"v"`
Count int `json:"count"`
Tail string `json:"tail"`
MAC string `json:"mac"`
}

// capStateAnchorMAC computes the keyed MAC over an anchor's fields.
func capStateAnchorMAC(key []byte, version, count int, tail string) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte("pilot-cap-state-anchor-v1"))
fmt.Fprintf(mac, "|%d|%d|%s", version, count, tail)
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

// capStateAnchorPath names the sidecar anchor for a cap-state file.
func capStateAnchorPath(path string) string { return path + capStateAnchorSuffix }

// capStateStrictAnchor reports whether the strict-anchor flag is set.
func capStateStrictAnchor() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(capStateAnchorEnv))) {
case "1", "true", "yes", "on":
return true
}
return false
}

// loadCapStateAnchor reads the sidecar anchor. A missing file is normal
// first-run state and returns (nil, nil). A file that is unparseable,
// of an unknown version, or whose MAC does not verify is an error.
func loadCapStateAnchor(path string, key []byte) (*capStateAnchor, error) {
// #nosec G304 -- derived from the cap-state path, which the sole production caller confines to the app store root
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var a capStateAnchor
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("malformed cap-state anchor %s: %w", path, err)
}
if a.Version != 1 {
return nil, fmt.Errorf("cap-state anchor %s: unsupported version %d", path, a.Version)
}
want := capStateAnchorMAC(key, a.Version, a.Count, a.Tail)
if !hmac.Equal([]byte(want), []byte(a.MAC)) {
return nil, fmt.Errorf("cap-state anchor %s: MAC mismatch", path)
}
return &a, nil
}

// writeCapStateAnchor writes the sidecar anchor atomically.
func writeCapStateAnchor(path string, key []byte, count int, tail string) error {
blob, err := json.Marshal(capStateAnchor{
Version: 1, Count: count, Tail: tail,
MAC: capStateAnchorMAC(key, 1, count, tail),
})
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, append(blob, '\n'), 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}

// loadCapStateRecordsAnchored loads the spend log and cross-checks it
// against the sidecar anchor left by the previous read.
//
// The per-record chain only commits to the records that precede each
// record, so any prefix of a valid chain is itself a valid chain: a log
// rewound to an earlier prefix verifies line by line and simply reports
// less usage. The anchor closes that by committing separately to the
// record count and to the final chain value, so a shorter or diverging
// log is rejected instead of accepted.
//
// Behaviour:
// - with no key, or with no authenticated records, the anchor is not
// consulted and records are returned as-is (legacy files keep working);
// - an authenticated chain with no anchor beside it is accepted and an
// anchor is written for it, unless PILOT_CAP_STATE_STRICT_ANCHOR is
// set (default unset), in which case it is an error;
// - fewer authenticated records than the anchor recorded is an error;
// - a record at the anchored position whose chain value differs from
// the anchored one is an error;
// - a strictly longer chain that still matches at the anchored
// position advances the anchor.
//
// Anchor writes are best-effort: a read-only install tree degrades to
// the previous behaviour rather than failing the command.
func loadCapStateRecordsAnchored(path string, hmacKey []byte) ([]capStateRecord, error) {
records, err := loadCapStateRecords(path, hmacKey)
if err != nil {
return nil, err
}
if hmacKey == nil {
return records, nil
}

// Measure the authenticated prefix and remember its chain value.
count, tail := 0, ""
for _, r := range records {
if len(r.chain) == 0 {
break
}
count++
tail = base64.StdEncoding.EncodeToString(r.chain)
}

anchorPath := capStateAnchorPath(path)
anchor, err := loadCapStateAnchor(anchorPath, hmacKey)
if err != nil {
return nil, err
}

if count == 0 {
// Nothing authenticated to anchor to: legacy or empty log. An
// anchor that recorded records is still a mismatch.
if anchor != nil && anchor.Count > 0 {
return nil, fmt.Errorf("cap-state %s: anchor records %d authenticated entries, none present now", path, anchor.Count)
}
return records, nil
}

if anchor == nil {
if capStateStrictAnchor() {
return nil, fmt.Errorf("cap-state %s: no anchor beside an authenticated chain (%s is set)", path, capStateAnchorEnv)
}
_ = writeCapStateAnchor(anchorPath, hmacKey, count, tail)
return records, nil
}

if count < anchor.Count {
return nil, fmt.Errorf("cap-state %s: %d authenticated records present, anchor records %d — entries removed", path, count, anchor.Count)
}
if anchor.Count > 0 {
at := base64.StdEncoding.EncodeToString(records[anchor.Count-1].chain)
if !hmac.Equal([]byte(at), []byte(anchor.Tail)) {
return nil, fmt.Errorf("cap-state %s: record %d does not match the anchored chain value — log rewritten", path, anchor.Count)
}
}
if count > anchor.Count {
_ = writeCapStateAnchor(anchorPath, hmacKey, count, tail)
}
return records, nil
}

// ── actions ────────────────────────────────────────────────────────────

// cmdAppStoreActions reads the root-level pilotctl-audit log written
Expand Down
162 changes: 162 additions & 0 deletions cmd/pilotctl/zz_appstore_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,168 @@ func mustDecodeB64(s string) []byte {
return b
}

// capStateChainWriter returns a helper that writes a cap-state file
// holding the first n records of a fixed chained sequence.
func capStateChainWriter(path string, key []byte) func(n int) {
type entry struct {
at string
asset string
amount uint64
}
entries := []entry{
{"2026-05-27T10:00:00Z", "USDC", 5},
{"2026-05-27T10:05:00Z", "ETH", 2},
{"2026-05-27T10:10:00Z", "USDC", 7},
{"2026-05-27T10:15:00Z", "USDC", 3},
}
return func(n int) {
var body strings.Builder
var prev []byte
for i := 0; i < n; i++ {
e := entries[i]
canonical, _ := json.Marshal(capStateJSONNoHMAC{
At: mustParseTime(e.at), Asset: e.asset, Amount: e.amount,
})
mac := hmac.New(sha256.New, key)
mac.Write(canonical)
mac.Write(prev)
sum := mac.Sum(nil)
prev = sum
fmt.Fprintf(&body, "{\"at\":%q,\"asset\":%q,\"amount\":%d,\"hmac\":%q}\n",
e.at, e.asset, e.amount, base64.StdEncoding.EncodeToString(sum))
}
if err := os.WriteFile(path, []byte(body.String()), 0o600); err != nil {
panic(err)
}
}
}

// TestLoadCapStateRecordsAnchoredDetectsTruncation covers the anchor
// side of the loader: every prefix of a chained log verifies line by
// line, so only the anchor distinguishes a log that grew from one that
// was rewound to an earlier prefix.
func TestLoadCapStateRecordsAnchoredDetectsTruncation(t *testing.T) {
t.Parallel()
key := bytes32(7)
dir := t.TempDir()
path := filepath.Join(dir, "cap-state.jsonl")
write := capStateChainWriter(path, key)

// First read of a 3-record chain adopts an anchor.
write(3)
recs, err := loadCapStateRecordsAnchored(path, key)
if err != nil || len(recs) != 3 {
t.Fatalf("first read: got %d records, err %v; want 3, nil", len(recs), err)
}
anchorPath := capStateAnchorPath(path)
if _, err := os.Stat(anchorPath); err != nil {
t.Fatalf("anchor not adopted on first read: %v", err)
}

// Re-reading the same file is stable.
if recs, err := loadCapStateRecordsAnchored(path, key); err != nil || len(recs) != 3 {
t.Fatalf("re-read: got %d records, err %v; want 3, nil", len(recs), err)
}

// Appending advances the anchor.
write(4)
if recs, err := loadCapStateRecordsAnchored(path, key); err != nil || len(recs) != 4 {
t.Fatalf("append: got %d records, err %v; want 4, nil", len(recs), err)
}

// Rewinding to an earlier prefix still verifies record by record...
write(2)
if recs, err := loadCapStateRecords(path, key); err != nil || len(recs) != 2 {
t.Fatalf("prefix should verify per-record: got %d records, err %v", len(recs), err)
}
// ...but must be rejected against the anchor.
if _, err := loadCapStateRecordsAnchored(path, key); err == nil {
t.Fatal("rewound log accepted — anchor did not catch the shorter chain")
} else if !strings.Contains(err.Error(), "entries removed") {
t.Errorf("err %q should report removed entries", err.Error())
}

// An emptied log is caught the same way.
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := loadCapStateRecordsAnchored(path, key); err == nil {
t.Fatal("emptied log accepted — anchor did not catch it")
}

// So is an anchor whose MAC has been edited.
write(4)
if err := os.WriteFile(anchorPath, []byte(`{"v":1,"count":1,"tail":"AAAA","mac":"AAAA"}`), 0o600); err != nil {
t.Fatalf("write anchor: %v", err)
}
if _, err := loadCapStateRecordsAnchored(path, key); err == nil {
t.Fatal("forged anchor accepted — MAC not checked")
} else if !strings.Contains(err.Error(), "MAC mismatch") {
t.Errorf("err %q should mention MAC mismatch", err.Error())
}
}

// TestLoadCapStateRecordsAnchoredLegacyStillLoads pins the backward
// compatibility contract: files that predate the anchor keep loading,
// and no anchor is written for an unauthenticated log.
func TestLoadCapStateRecordsAnchoredLegacyStillLoads(t *testing.T) {
t.Parallel()
key := bytes32(3)
dir := t.TempDir()
path := filepath.Join(dir, "cap-state.jsonl")

// Legacy: no hmac fields at all, key available.
legacy := `{"at":"2026-05-27T10:00:00Z","asset":"USDC","amount":5}
{"at":"2026-05-27T10:05:00Z","asset":"ETH","amount":2}
`
if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
recs, err := loadCapStateRecordsAnchored(path, key)
if err != nil || len(recs) != 2 {
t.Fatalf("legacy file: got %d records, err %v; want 2, nil", len(recs), err)
}
if _, err := os.Stat(capStateAnchorPath(path)); !errors.Is(err, os.ErrNotExist) {
t.Errorf("anchor written for an unauthenticated log (stat err %v)", err)
}

// Missing file stays first-run state.
if recs, err := loadCapStateRecordsAnchored(filepath.Join(dir, "nope.jsonl"), key); err != nil || recs != nil {
t.Fatalf("missing file: got %v, err %v; want nil, nil", recs, err)
}

// No key: anchor logic is skipped entirely.
write := capStateChainWriter(path, key)
write(3)
if recs, err := loadCapStateRecordsAnchored(path, nil); err != nil || len(recs) != 3 {
t.Fatalf("nil key: got %d records, err %v; want 3, nil", len(recs), err)
}
}

// TestLoadCapStateRecordsStrictAnchorFlag checks that rejecting an
// anchorless authenticated chain is off unless the flag is set.
func TestLoadCapStateRecordsStrictAnchorFlag(t *testing.T) {
key := bytes32(11)
dir := t.TempDir()
path := filepath.Join(dir, "cap-state.jsonl")
capStateChainWriter(path, key)(2)

// Default (flag unset): adopt.
t.Setenv(capStateAnchorEnv, "")
if recs, err := loadCapStateRecordsAnchored(path, key); err != nil || len(recs) != 2 {
t.Fatalf("default: got %d records, err %v; want 2, nil", len(recs), err)
}
if err := os.Remove(capStateAnchorPath(path)); err != nil {
t.Fatalf("remove anchor: %v", err)
}

// Flag set: an anchorless authenticated chain is refused.
t.Setenv(capStateAnchorEnv, "1")
if _, err := loadCapStateRecordsAnchored(path, key); err == nil {
t.Fatal("strict mode accepted an anchorless authenticated chain")
}
}

// TestResolveUnderRejectsTraversal exercises the containment guard the
// install staging path uses on manifest binary.path. A path that
// resolves outside the bundle/staging dir (../../etc/x, an absolute
Expand Down
Loading