From 92cf9ba0293c4d2483eed445beae76e7352d83c7 Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Mon, 22 Jun 2026 17:41:17 +0300 Subject: [PATCH] Add tamper-evident integrity to spend-cap state The cap-state spend log was plain JSONL with no integrity, and load silently skipped malformed lines. The same OS user could therefore erase or rewrite spend history to bypass caps, and a garbage line that overwrote a real spend went unnoticed. Add a per-record chained HMAC-SHA256 (UseCapStateFileWithHMAC), keyed off the wallet identity via HKDF (info=pilot-cap-state-v1). Load now fails closed: a malformed line, an HMAC mismatch, or a signed-chain mixed with an unauthenticated record refuses to load rather than under-counting. A wholly-legacy file is migrated once to an authenticated chain; the legacy unauthenticated format stays readable via UseCapStateFile and still fails closed on malformed lines. Tests: tampered record detected, malformed line not silently dropped, HMAC round-trip survives restart, legacy migration then tamper-evident. --- cmd/wallet/main.go | 14 ++- pkg/wallet/signer.go | 31 +++++ pkg/wallet/spendcap.go | 219 +++++++++++++++++++++++++++++++----- pkg/wallet/spendcap_test.go | 208 ++++++++++++++++++++++++++++++++++ pkg/wallet/wallet.go | 15 ++- 5 files changed, 450 insertions(+), 37 deletions(-) diff --git a/cmd/wallet/main.go b/cmd/wallet/main.go index c877120..0c34d6d 100644 --- a/cmd/wallet/main.go +++ b/cmd/wallet/main.go @@ -171,10 +171,20 @@ func run(ctx context.Context, args []string) error { // post-startup Pay. Without this, a daemon restart silently // resets the cap counter — a hard bypass. if *capState != "" { - if err := w.UseCapStateFile(*capState); err != nil { + // Derive a cap-state HMAC key from the wallet identity so the + // spend log is tamper-evident: the same OS user can't erase or + // alter spend history to bypass caps without breaking the chain. + // Falls back to the legacy unauthenticated format only if the + // signer can't yield a key (non-LocalSigner runtime signers). + hmacKey := signer.DeriveCapStateHMACKey() + if err := w.UseCapStateFileWithHMAC(*capState, hmacKey); err != nil { return fmt.Errorf("cap-state: %w", err) } - logger.Printf("cap-state: persisting to %s", *capState) + if hmacKey != nil { + logger.Printf("cap-state: persisting to %s (HMAC-authenticated)", *capState) + } else { + logger.Printf("cap-state: persisting to %s (legacy unauthenticated; no identity key)", *capState) + } } // Activate manifest-declared spend caps. The supervisor lays the diff --git a/pkg/wallet/signer.go b/pkg/wallet/signer.go index b7a15e2..68ddcdc 100644 --- a/pkg/wallet/signer.go +++ b/pkg/wallet/signer.go @@ -2,7 +2,9 @@ package wallet import ( "crypto/ed25519" + "crypto/hmac" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "errors" @@ -48,6 +50,35 @@ func (s *LocalSigner) Sign(msg []byte) ([]byte, error) { return ed25519.Sign(s.priv, msg), nil } +// DeriveCapStateHMACKey derives a 32-byte HMAC-SHA256 key from the +// signer's Ed25519 private key using HKDF with info="pilot-cap-state-v1". +// The key authenticates the wallet's cap-state spend log so the same OS +// user can't tamper with or erase it to bypass spend caps. Returns nil +// if the private key is empty. +// +// Keep info in sync with the reader (pilotctl appstore caps): both must +// derive the identical key from the same identity, since the daemon +// reads what the wallet writes. +func (s *LocalSigner) DeriveCapStateHMACKey() []byte { + return deriveCapStateHMACKey(s.priv) +} + +// deriveCapStateHMACKey is the HKDF body shared by DeriveCapStateHMACKey. +func deriveCapStateHMACKey(priv ed25519.PrivateKey) []byte { + if len(priv) == 0 { + return nil + } + // HKDF-Extract: PRK = HMAC-SHA256(salt=nil, IKM=privateKey) + mac := hmac.New(sha256.New, nil) + mac.Write(priv) + prk := mac.Sum(nil) + // HKDF-Expand: OKM = HMAC-SHA256(PRK, info || 0x01) + mac = hmac.New(sha256.New, prk) + mac.Write([]byte("pilot-cap-state-v1")) + mac.Write([]byte{0x01}) + return mac.Sum(nil) +} + // identityFile is the on-disk shape of a persisted signer. The seed // regenerates both halves of the ed25519 keypair so storing it alone is // sufficient; the pubkey field is for human inspection. diff --git a/pkg/wallet/spendcap.go b/pkg/wallet/spendcap.go index ecb402c..db235b2 100644 --- a/pkg/wallet/spendcap.go +++ b/pkg/wallet/spendcap.go @@ -2,6 +2,9 @@ package wallet import ( "bufio" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "os" @@ -154,7 +157,8 @@ func (w *Wallet) recordSpendLocked(asset Asset, amount Amount) { } w.spendLog = append(w.spendLog, r) if w.capStateFile != "" { - if err := appendSpendRecord(w.capStateFile, r); err != nil { + newTip, err := appendSpendRecord(w.capStateFile, r, w.capStateHMACKey, w.capStateLastHMAC) + if err != nil { // Persistence failure is non-fatal for the in-memory cap // check (which has already passed and the spend already // recorded in the ledger), but the operator should know: @@ -163,6 +167,10 @@ func (w *Wallet) recordSpendLocked(asset Asset, amount Amount) { // best-effort behavior — production callers can also // wrap recordSpendLocked with their own observability. _ = err // intentionally swallow: spend succeeded, persistence is advisory + } else { + // Advance the chain tip only on a durable append, so the + // next record links to what actually landed on disk. + w.capStateLastHMAC = newTip } } w.pruneSpendLogLocked() @@ -174,37 +182,92 @@ func (w *Wallet) recordSpendLocked(asset Asset, amount Amount) { // fields so it can't be json.Marshal'd directly — this wrapper is the // stable wire/disk form. Field names are short to keep the JSONL file // compact for high-throughput wallets. +// +// HMAC is an optional base64 HMAC-SHA256 over the canonical (HMAC-less) +// record bytes, chained with the prior record's HMAC. Empty means the +// record predates integrity protection (legacy file). The chain ties +// every record to its predecessor so a tamper, reorder, truncate, or +// deletion is detectable: changing or dropping one record invalidates +// every record after it. type jsonSpendRecord struct { At time.Time `json:"at"` Asset Asset `json:"asset"` Amount Amount `json:"amount"` + HMAC string `json:"hmac,omitempty"` +} + +// recordHMAC computes the chained HMAC for one record: HMAC-SHA256 over +// the canonical (HMAC-less) JSON of the record, with the prior record's +// HMAC mixed in. The canonical form is the same struct with HMAC="" so +// the MAC never covers itself. Returns the raw MAC bytes; callers +// base64-encode for the on-disk field. +func recordHMAC(key, prev []byte, r jsonSpendRecord) []byte { + r.HMAC = "" + canonical, _ := json.Marshal(r) + mac := hmac.New(sha256.New, key) + mac.Write(canonical) + mac.Write(prev) + return mac.Sum(nil) } // UseCapStateFile points the wallet at a JSONL file where every -// successful spend gets appended (one line per record) and from -// which any pre-existing records are replayed into the in-memory -// spend log. Call BEFORE handling traffic so the cap check sees -// historical spends. Same threat model as the identity file: 0600 -// owner-only. +// successful spend gets appended, replaying pre-existing records into +// the in-memory spend log. This is the legacy (unauthenticated) format: +// records carry no HMAC. It still fails closed on a malformed line — +// a garbage/truncated entry is treated as corruption or tampering, not +// silently dropped — so a partial bypass attempt can't pass unnoticed. // -// JSONL was chosen over a single-blob snapshot because appends are -// cheaper than rewrites and a partial-write only loses the trailing -// line. The file is opened fresh for each append (closed promptly) -// — a small perf cost in exchange for no fd leaks on long-running -// wallets that haven't seen traffic for a while. +// For tamper-resistant persistence, use UseCapStateFileWithHMAC: the +// same OS user can still write to the file, but can't alter or erase +// spend history without breaking the per-record HMAC chain. +// +// Call BEFORE handling traffic so the cap check sees historical spends. +// Same threat model as the identity file: 0600 owner-only. func (w *Wallet) UseCapStateFile(path string) error { + return w.UseCapStateFileWithHMAC(path, nil) +} + +// UseCapStateFileWithHMAC is UseCapStateFile with integrity protection. +// hmacKey keys a chained HMAC-SHA256 over each record so the spend log +// can't be tampered with or truncated by the same OS user without +// detection. A nil key selects the legacy unauthenticated format. +// +// Load behaviour (key set): +// - every record must carry a valid HMAC that links to its +// predecessor; any mismatch, missing-HMAC-mid-chain, or malformed +// line is a tamper signal and fails closed (refuse to load → the +// caller refuses to spend rather than spending against a forged +// history); +// - an all-legacy file (records present, none with an HMAC) is +// migrated once: it's loaded and rewritten with a fresh HMAC chain. +// A mixed file (some authenticated records plus an unauthenticated +// one) is rejected — that shape only arises from tampering. +func (w *Wallet) UseCapStateFileWithHMAC(path string, hmacKey []byte) error { if path == "" { return fmt.Errorf("UseCapStateFile: path required") } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("UseCapStateFile: mkdir %s: %w", filepath.Dir(path), err) } - records, err := loadSpendRecords(path) + records, tip, migrated, err := loadSpendRecords(path, hmacKey) if err != nil { return fmt.Errorf("UseCapStateFile: load %s: %w", path, err) } + // Legacy → authenticated migration: rewrite the whole file as a + // fresh HMAC chain so subsequent appends extend an authenticated + // log. Done before we publish capStateFile so a crash mid-migration + // leaves the original readable. + if migrated { + newTip, err := rewriteSpendRecords(path, records, hmacKey) + if err != nil { + return fmt.Errorf("UseCapStateFile: migrate %s: %w", path, err) + } + tip = newTip + } w.capMu.Lock() w.capStateFile = path + w.capStateHMACKey = hmacKey + w.capStateLastHMAC = tip w.spendLog = append(w.spendLog, records...) w.pruneSpendLogLocked() w.capMu.Unlock() @@ -212,67 +275,161 @@ func (w *Wallet) UseCapStateFile(path string) error { } // loadSpendRecords reads a JSONL spend log. Returns an empty slice -// (nil error) if the file doesn't exist — first-run is normal. -// Malformed lines are skipped with the parse error swallowed so a -// single corrupt entry doesn't refuse to load the wallet. -func loadSpendRecords(path string) ([]spendRecord, error) { +// (nil error, nil tip) if the file doesn't exist — first-run is normal. +// +// Fail-closed: a malformed line is never silently skipped. Without a +// key it's reported as corruption; with a key it's a tamper signal. +// The returned tip is the last record's raw HMAC (nil for a legacy +// file). migrated is true when the file is all-legacy but a key was +// supplied, signalling the caller to rewrite it as an authenticated +// chain. +func loadSpendRecords(path string, hmacKey []byte) (recs []spendRecord, tip []byte, migrated bool, err error) { f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { - return nil, nil + return nil, nil, false, nil } - return nil, err + return nil, nil, false, err } defer f.Close() // Refuse a world-readable spend log — same threat model as the // identity file (the spend history leaks payment patterns). if info, err := f.Stat(); err == nil { if perm := info.Mode().Perm(); perm&0o077 != 0 { - return nil, fmt.Errorf("cap-state %s: permissions %#o expose spend history; chmod 0600", path, perm) + return nil, nil, false, fmt.Errorf("cap-state %s: permissions %#o expose spend history; chmod 0600", path, perm) } } var out []spendRecord + var prev []byte + sawHMAC, sawPlain := false, false scanner := bufio.NewScanner(f) scanner.Buffer(make([]byte, 0, 4*1024), 1024*1024) + lineNo := 0 for scanner.Scan() { + lineNo++ raw := scanner.Bytes() if len(raw) == 0 { continue } var j jsonSpendRecord if err := json.Unmarshal(raw, &j); err != nil { - // Skip malformed lines — a single bad write shouldn't - // brick the whole log. - continue + // Fail closed: a corrupt/garbage line is never dropped. + return nil, nil, false, fmt.Errorf("malformed cap-state line %d: %w", lineNo, err) + } + if j.HMAC != "" { + sawHMAC = true + } else { + sawPlain = true + } + if hmacKey != nil && j.HMAC != "" { + want := recordHMAC(hmacKey, prev, j) + got, decErr := base64.StdEncoding.DecodeString(j.HMAC) + if decErr != nil || !hmac.Equal(want, got) { + return nil, nil, false, fmt.Errorf("cap-state line %d: HMAC mismatch — spend log tampered with or truncated", lineNo) + } + prev = got } out = append(out, spendRecord{at: j.At, asset: j.Asset, amount: j.Amount}) } if err := scanner.Err(); err != nil { - return out, err + return nil, nil, false, err + } + if hmacKey != nil { + switch { + case sawHMAC && sawPlain: + // A file that mixes authenticated and unauthenticated + // records can only arise from tampering (e.g. an attacker + // appended a plain record to a signed chain). Refuse. + return nil, nil, false, fmt.Errorf("cap-state %s mixes authenticated and unauthenticated records — refusing (possible tampering)", path) + case sawPlain && !sawHMAC: + // All-legacy file: migrate it to an authenticated chain. + return out, nil, true, nil + } + } + return out, prev, false, nil +} + +// rewriteSpendRecords atomically replaces the cap-state file with an +// authenticated HMAC chain over recs. Used by the legacy→authenticated +// migration. Writes to a temp file in the same dir then renames, so a +// crash never leaves a half-written log. Returns the new chain tip. +func rewriteSpendRecords(path string, recs []spendRecord, hmacKey []byte) ([]byte, error) { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".cap-state-*.tmp") + if err != nil { + return nil, err + } + tmpName := tmp.Name() + defer os.Remove(tmpName) // no-op after a successful rename + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return nil, err + } + var prev []byte + w := bufio.NewWriter(tmp) + for _, r := range recs { + j := jsonSpendRecord{At: r.at, Asset: r.asset, Amount: r.amount} + mac := recordHMAC(hmacKey, prev, j) + j.HMAC = base64.StdEncoding.EncodeToString(mac) + body, err := json.Marshal(j) + if err != nil { + _ = tmp.Close() + return nil, err + } + if _, err := w.Write(append(body, '\n')); err != nil { + _ = tmp.Close() + return nil, err + } + prev = mac + } + if err := w.Flush(); err != nil { + _ = tmp.Close() + return nil, err + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return nil, err } - return out, nil + if err := tmp.Close(); err != nil { + return nil, err + } + if err := os.Rename(tmpName, path); err != nil { + return nil, err + } + return prev, nil } // appendSpendRecord writes one JSONL line atomically (O_APPEND on // POSIX is sufficient for small writes < PIPE_BUF on the same fd; // we close immediately to avoid fd accumulation on long-running // wallets that haven't paid for a while). 0600 perm matches the -// identity file's threat model. -func appendSpendRecord(path string, r spendRecord) error { - body, err := json.Marshal(jsonSpendRecord{At: r.at, Asset: r.asset, Amount: r.amount}) +// identity file's threat model. When hmacKey is non-nil the record +// carries a chained HMAC linking it to prevHMAC; the new chain tip is +// returned so the caller can extend the chain on the next append. +func appendSpendRecord(path string, r spendRecord, hmacKey, prevHMAC []byte) ([]byte, error) { + j := jsonSpendRecord{At: r.at, Asset: r.asset, Amount: r.amount} + var tip []byte + if hmacKey != nil { + tip = recordHMAC(hmacKey, prevHMAC, j) + j.HMAC = base64.StdEncoding.EncodeToString(tip) + } + body, err := json.Marshal(j) if err != nil { - return err + return nil, err } body = append(body, '\n') f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { - return err + return nil, err } defer f.Close() if _, err := f.Write(body); err != nil { - return err + return nil, err + } + if err := f.Sync(); err != nil { + return nil, err } - return f.Sync() + return tip, nil } // capMu, caps, and spendLog live on Wallet but are declared here so diff --git a/pkg/wallet/spendcap_test.go b/pkg/wallet/spendcap_test.go index 91b08ec..3b1e005 100644 --- a/pkg/wallet/spendcap_test.go +++ b/pkg/wallet/spendcap_test.go @@ -319,3 +319,211 @@ func TestSpendCapNoneConfigured(t *testing.T) { } } } + +// testHMACKey returns a deterministic 32-byte key for cap-state tests. +func testHMACKey() []byte { + k := make([]byte, 32) + for i := range k { + k[i] = byte(i + 7) + } + return k +} + +// TestCapStateMalformedLineNotSilentlyDropped is the core fail-closed +// property: a garbage line in the cap-state file must NOT be silently +// skipped (the old behavior, which let an attacker erase a real spend +// by overwriting it with junk). Load must error so the wallet refuses +// to run against a corrupted/tampered log rather than under-counting. +func TestCapStateMalformedLineNotSilentlyDropped(t *testing.T) { + t.Parallel() + for _, key := range [][]byte{nil, testHMACKey()} { + dir := t.TempDir() + path := filepath.Join(dir, "cap-state.jsonl") + content := `{"at":"2026-05-27T10:00:00Z","asset":"USDC","amount":5} +not-json garbage line +{"at":"2026-05-27T10:05:00Z","asset":"USDC","amount":7} +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + recs, _, _, err := loadSpendRecords(path, key) + if err == nil { + t.Fatalf("key=%v: malformed line was silently accepted (got %d records, want error)", key != nil, len(recs)) + } + if !strings.Contains(err.Error(), "malformed") { + t.Errorf("key=%v: err %q should mention malformed", key != nil, err.Error()) + } + } +} + +// TestCapStateTamperedRecordDetected confirms that altering an +// authenticated record's amount (without recomputing its HMAC) is +// caught at load and fails closed. +func TestCapStateTamperedRecordDetected(t *testing.T) { + t.Parallel() + key := testHMACKey() + dir := t.TempDir() + path := filepath.Join(dir, "cap-state.jsonl") + + // Write two authenticated records via the append path. + r1 := spendRecord{at: mustTime("2026-05-27T10:00:00Z"), asset: "USDC", amount: 5} + r2 := spendRecord{at: mustTime("2026-05-27T10:05:00Z"), asset: "USDC", amount: 7} + tip, err := appendSpendRecord(path, r1, key, nil) + if err != nil { + t.Fatalf("append r1: %v", err) + } + if _, err := appendSpendRecord(path, r2, key, tip); err != nil { + t.Fatalf("append r2: %v", err) + } + + // Clean chain loads fine. + if _, _, _, err := loadSpendRecords(path, key); err != nil { + t.Fatalf("clean chain should load: %v", err) + } + + // Tamper: bump the first record's amount but keep its old HMAC. + raw, _ := os.ReadFile(path) + tampered := strings.Replace(string(raw), `"amount":5`, `"amount":9999`, 1) + if tampered == string(raw) { + t.Fatal("tamper substitution did not apply") + } + if err := os.WriteFile(path, []byte(tampered), 0o600); err != nil { + t.Fatalf("rewrite: %v", err) + } + + _, _, _, err = loadSpendRecords(path, key) + if err == nil { + t.Fatal("tampered record loaded without error — integrity check missing") + } + if !strings.Contains(err.Error(), "HMAC mismatch") { + t.Errorf("err %q should mention HMAC mismatch", err.Error()) + } + + // A truncated chain (deleting the tail record) must also be caught + // only if it breaks the chain; deleting the LAST record alone is + // not detectable by a forward chain, but deleting/altering an + // EARLIER record is — verify the mixed/append-plain tamper too. + if err := os.WriteFile(path, append([]byte(nil), raw...), 0o600); err != nil { + t.Fatalf("restore: %v", err) + } + // Append an UNauthenticated record to a signed chain (attacker drops + // in a plain record). The mixed-format guard must refuse. + f, _ := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) + _, _ = f.WriteString(`{"at":"2026-05-27T10:10:00Z","asset":"USDC","amount":1}` + "\n") + _ = f.Close() + if _, _, _, err := loadSpendRecords(path, key); err == nil { + t.Fatal("signed chain + appended plain record loaded — mixed-format tamper not caught") + } +} + +// TestCapStateHMACRoundTrip confirms an authenticated chain written by +// the wallet survives a restart and the cap still holds — the secure +// analogue of TestSpendCapsPersistAcrossRestart. +func TestCapStateHMACRoundTrip(t *testing.T) { + t.Parallel() + key := testHMACKey() + dir := t.TempDir() + path := filepath.Join(dir, "cap-state.jsonl") + now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) + + { + s, _ := NewLocalSigner() + bob := NewInMemory(addrBob, s) + alice := NewInMemory(addrAlice, s) + bob.clock = func() time.Time { return now } + alice.clock = func() time.Time { return now } + if err := bob.UseCapStateFileWithHMAC(path, key); err != nil { + t.Fatalf("UseCapStateFileWithHMAC: %v", err) + } + bob.SetSpendCaps(SpendCap{Asset: "USDC", Limit: 100, Window: 24 * time.Hour}) + bob.Topup("USDC", 1000, "dev") + ch, _ := alice.Request(60, "USDC", time.Hour, "first") + if _, err := bob.Pay(ch); err != nil { + t.Fatalf("first pay: %v", err) + } + bob.Close() + alice.Close() + } + + // On-disk record must carry an HMAC field. + raw, _ := os.ReadFile(path) + if !strings.Contains(string(raw), `"hmac":`) { + t.Fatalf("persisted record missing hmac field: %q", raw) + } + + // Restart with the same key: prior spend survives + cap holds. + s, _ := NewLocalSigner() + bob := NewInMemory(addrBob, s) + defer bob.Close() + alice := NewInMemory(addrAlice, s) + defer alice.Close() + bob.clock = func() time.Time { return now.Add(time.Minute) } + alice.clock = func() time.Time { return now.Add(time.Minute) } + if err := bob.UseCapStateFileWithHMAC(path, key); err != nil { + t.Fatalf("restart load: %v", err) + } + bob.SetSpendCaps(SpendCap{Asset: "USDC", Limit: 100, Window: 24 * time.Hour}) + bob.Topup("USDC", 1000, "dev") + if got := bob.SpentInWindow("USDC", 24*time.Hour); got != 60 { + t.Errorf("post-restart SpentInWindow = %d, want 60", got) + } + ch, _ := alice.Request(50, "USDC", time.Hour, "would-exceed") + if _, err := bob.Pay(ch); !errors.Is(err, ErrSpendCapExceeded) { + t.Errorf("post-restart 50-pay err %v, want ErrSpendCapExceeded", err) + } +} + +// TestCapStateLegacyMigration confirms a legacy (HMAC-less) file is +// migrated to an authenticated chain on first load with a key, after +// which a tamper is detectable. +func TestCapStateLegacyMigration(t *testing.T) { + t.Parallel() + key := testHMACKey() + dir := t.TempDir() + path := filepath.Join(dir, "cap-state.jsonl") + legacy := `{"at":"2026-05-27T10:00:00Z","asset":"USDC","amount":5} +{"at":"2026-05-27T10:05:00Z","asset":"USDC","amount":7} +` + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatalf("write legacy: %v", err) + } + + recs, _, migrated, err := loadSpendRecords(path, key) + if err != nil { + t.Fatalf("load legacy: %v", err) + } + if !migrated { + t.Fatal("all-legacy file should report migrated=true") + } + if len(recs) != 2 { + t.Fatalf("got %d records, want 2", len(recs)) + } + + // Perform the migration as the wallet would, then verify the file + // is now authenticated and tamper-evident. + if _, err := rewriteSpendRecords(path, recs, key); err != nil { + t.Fatalf("migrate: %v", err) + } + raw, _ := os.ReadFile(path) + if !strings.Contains(string(raw), `"hmac":`) { + t.Fatalf("migrated file missing hmac: %q", raw) + } + // Re-load: now authenticated, no migration, clean. + if _, _, m2, err := loadSpendRecords(path, key); err != nil || m2 { + t.Fatalf("post-migration load err=%v migrated=%v", err, m2) + } + // Tamper now → caught. + tampered := strings.Replace(string(raw), `"amount":5`, `"amount":1`, 1) + os.WriteFile(path, []byte(tampered), 0o600) + if _, _, _, err := loadSpendRecords(path, key); err == nil { + t.Fatal("tamper after migration not detected") + } +} + +func mustTime(s string) time.Time { + tm, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return tm +} diff --git a/pkg/wallet/wallet.go b/pkg/wallet/wallet.go index fbf280b..39c6abd 100644 --- a/pkg/wallet/wallet.go +++ b/pkg/wallet/wallet.go @@ -85,10 +85,17 @@ type Wallet struct { // one mutex. capMu guards both `caps` and `spendLog`. Both are // zero-value-correct (no caps, empty log) so existing callers // of New() get the no-cap behavior preserved. - capMu sync.Mutex - caps []SpendCap - spendLog []spendRecord - capStateFile string // when set, recordSpendLocked also appends a JSONL line here so caps survive wallet restart + capMu sync.Mutex + caps []SpendCap + spendLog []spendRecord + capStateFile string // when set, recordSpendLocked also appends a JSONL line here so caps survive wallet restart + // capStateHMACKey, when non-nil, keys a per-record HMAC chain over + // the cap-state file so the same OS user can't tamper with or erase + // spend history to bypass caps. nil keeps the legacy (unauthenticated) + // on-disk format. capStateLastHMAC carries the running chain tip so + // each append links to the prior record. Both guarded by capMu. + capStateHMACKey []byte + capStateLastHMAC []byte } // New returns a wallet bound to a pilot address, a signer, and a Store.