diff --git a/cmd/pilotctl/appstore.go b/cmd/pilotctl/appstore.go index 9ad3e311..ab4c251d 100644 --- a/cmd/pilotctl/appstore.go +++ b/cmd/pilotctl/appstore.go @@ -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 @@ -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 @@ -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) @@ -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 diff --git a/cmd/pilotctl/zz_appstore_helpers_test.go b/cmd/pilotctl/zz_appstore_helpers_test.go index 677f3806..b59ebb73 100644 --- a/cmd/pilotctl/zz_appstore_helpers_test.go +++ b/cmd/pilotctl/zz_appstore_helpers_test.go @@ -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