@@ -2,6 +2,9 @@ package wallet
22
33import (
44 "bufio"
5+ "crypto/hmac"
6+ "crypto/sha256"
7+ "encoding/base64"
58 "encoding/json"
69 "fmt"
710 "os"
@@ -154,7 +157,8 @@ func (w *Wallet) recordSpendLocked(asset Asset, amount Amount) {
154157 }
155158 w .spendLog = append (w .spendLog , r )
156159 if w .capStateFile != "" {
157- if err := appendSpendRecord (w .capStateFile , r ); err != nil {
160+ newTip , err := appendSpendRecord (w .capStateFile , r , w .capStateHMACKey , w .capStateLastHMAC )
161+ if err != nil {
158162 // Persistence failure is non-fatal for the in-memory cap
159163 // check (which has already passed and the spend already
160164 // recorded in the ledger), but the operator should know:
@@ -163,6 +167,10 @@ func (w *Wallet) recordSpendLocked(asset Asset, amount Amount) {
163167 // best-effort behavior — production callers can also
164168 // wrap recordSpendLocked with their own observability.
165169 _ = err // intentionally swallow: spend succeeded, persistence is advisory
170+ } else {
171+ // Advance the chain tip only on a durable append, so the
172+ // next record links to what actually landed on disk.
173+ w .capStateLastHMAC = newTip
166174 }
167175 }
168176 w .pruneSpendLogLocked ()
@@ -174,105 +182,254 @@ func (w *Wallet) recordSpendLocked(asset Asset, amount Amount) {
174182// fields so it can't be json.Marshal'd directly — this wrapper is the
175183// stable wire/disk form. Field names are short to keep the JSONL file
176184// compact for high-throughput wallets.
185+ //
186+ // HMAC is an optional base64 HMAC-SHA256 over the canonical (HMAC-less)
187+ // record bytes, chained with the prior record's HMAC. Empty means the
188+ // record predates integrity protection (legacy file). The chain ties
189+ // every record to its predecessor so a tamper, reorder, truncate, or
190+ // deletion is detectable: changing or dropping one record invalidates
191+ // every record after it.
177192type jsonSpendRecord struct {
178193 At time.Time `json:"at"`
179194 Asset Asset `json:"asset"`
180195 Amount Amount `json:"amount"`
196+ HMAC string `json:"hmac,omitempty"`
197+ }
198+
199+ // recordHMAC computes the chained HMAC for one record: HMAC-SHA256 over
200+ // the canonical (HMAC-less) JSON of the record, with the prior record's
201+ // HMAC mixed in. The canonical form is the same struct with HMAC="" so
202+ // the MAC never covers itself. Returns the raw MAC bytes; callers
203+ // base64-encode for the on-disk field.
204+ func recordHMAC (key , prev []byte , r jsonSpendRecord ) []byte {
205+ r .HMAC = ""
206+ canonical , _ := json .Marshal (r )
207+ mac := hmac .New (sha256 .New , key )
208+ mac .Write (canonical )
209+ mac .Write (prev )
210+ return mac .Sum (nil )
181211}
182212
183213// UseCapStateFile points the wallet at a JSONL file where every
184- // successful spend gets appended (one line per record) and from
185- // which any pre-existing records are replayed into the in-memory
186- // spend log. Call BEFORE handling traffic so the cap check sees
187- // historical spends. Same threat model as the identity file: 0600
188- // owner-only .
214+ // successful spend gets appended, replaying pre-existing records into
215+ // the in-memory spend log. This is the legacy (unauthenticated) format:
216+ // records carry no HMAC. It still fails closed on a malformed line —
217+ // a garbage/truncated entry is treated as corruption or tampering, not
218+ // silently dropped — so a partial bypass attempt can't pass unnoticed .
189219//
190- // JSONL was chosen over a single-blob snapshot because appends are
191- // cheaper than rewrites and a partial-write only loses the trailing
192- // line. The file is opened fresh for each append (closed promptly)
193- // — a small perf cost in exchange for no fd leaks on long-running
194- // wallets that haven't seen traffic for a while.
220+ // For tamper-resistant persistence, use UseCapStateFileWithHMAC: the
221+ // same OS user can still write to the file, but can't alter or erase
222+ // spend history without breaking the per-record HMAC chain.
223+ //
224+ // Call BEFORE handling traffic so the cap check sees historical spends.
225+ // Same threat model as the identity file: 0600 owner-only.
195226func (w * Wallet ) UseCapStateFile (path string ) error {
227+ return w .UseCapStateFileWithHMAC (path , nil )
228+ }
229+
230+ // UseCapStateFileWithHMAC is UseCapStateFile with integrity protection.
231+ // hmacKey keys a chained HMAC-SHA256 over each record so the spend log
232+ // can't be tampered with or truncated by the same OS user without
233+ // detection. A nil key selects the legacy unauthenticated format.
234+ //
235+ // Load behaviour (key set):
236+ // - every record must carry a valid HMAC that links to its
237+ // predecessor; any mismatch, missing-HMAC-mid-chain, or malformed
238+ // line is a tamper signal and fails closed (refuse to load → the
239+ // caller refuses to spend rather than spending against a forged
240+ // history);
241+ // - an all-legacy file (records present, none with an HMAC) is
242+ // migrated once: it's loaded and rewritten with a fresh HMAC chain.
243+ // A mixed file (some authenticated records plus an unauthenticated
244+ // one) is rejected — that shape only arises from tampering.
245+ func (w * Wallet ) UseCapStateFileWithHMAC (path string , hmacKey []byte ) error {
196246 if path == "" {
197247 return fmt .Errorf ("UseCapStateFile: path required" )
198248 }
199249 if err := os .MkdirAll (filepath .Dir (path ), 0o700 ); err != nil {
200250 return fmt .Errorf ("UseCapStateFile: mkdir %s: %w" , filepath .Dir (path ), err )
201251 }
202- records , err := loadSpendRecords (path )
252+ records , tip , migrated , err := loadSpendRecords (path , hmacKey )
203253 if err != nil {
204254 return fmt .Errorf ("UseCapStateFile: load %s: %w" , path , err )
205255 }
256+ // Legacy → authenticated migration: rewrite the whole file as a
257+ // fresh HMAC chain so subsequent appends extend an authenticated
258+ // log. Done before we publish capStateFile so a crash mid-migration
259+ // leaves the original readable.
260+ if migrated {
261+ newTip , err := rewriteSpendRecords (path , records , hmacKey )
262+ if err != nil {
263+ return fmt .Errorf ("UseCapStateFile: migrate %s: %w" , path , err )
264+ }
265+ tip = newTip
266+ }
206267 w .capMu .Lock ()
207268 w .capStateFile = path
269+ w .capStateHMACKey = hmacKey
270+ w .capStateLastHMAC = tip
208271 w .spendLog = append (w .spendLog , records ... )
209272 w .pruneSpendLogLocked ()
210273 w .capMu .Unlock ()
211274 return nil
212275}
213276
214277// loadSpendRecords reads a JSONL spend log. Returns an empty slice
215- // (nil error) if the file doesn't exist — first-run is normal.
216- // Malformed lines are skipped with the parse error swallowed so a
217- // single corrupt entry doesn't refuse to load the wallet.
218- func loadSpendRecords (path string ) ([]spendRecord , error ) {
278+ // (nil error, nil tip) if the file doesn't exist — first-run is normal.
279+ //
280+ // Fail-closed: a malformed line is never silently skipped. Without a
281+ // key it's reported as corruption; with a key it's a tamper signal.
282+ // The returned tip is the last record's raw HMAC (nil for a legacy
283+ // file). migrated is true when the file is all-legacy but a key was
284+ // supplied, signalling the caller to rewrite it as an authenticated
285+ // chain.
286+ func loadSpendRecords (path string , hmacKey []byte ) (recs []spendRecord , tip []byte , migrated bool , err error ) {
219287 f , err := os .Open (path )
220288 if err != nil {
221289 if os .IsNotExist (err ) {
222- return nil , nil
290+ return nil , nil , false , nil
223291 }
224- return nil , err
292+ return nil , nil , false , err
225293 }
226294 defer f .Close ()
227295 // Refuse a world-readable spend log — same threat model as the
228296 // identity file (the spend history leaks payment patterns).
229297 if info , err := f .Stat (); err == nil {
230298 if perm := info .Mode ().Perm (); perm & 0o077 != 0 {
231- return nil , fmt .Errorf ("cap-state %s: permissions %#o expose spend history; chmod 0600" , path , perm )
299+ return nil , nil , false , fmt .Errorf ("cap-state %s: permissions %#o expose spend history; chmod 0600" , path , perm )
232300 }
233301 }
234302 var out []spendRecord
303+ var prev []byte
304+ sawHMAC , sawPlain := false , false
235305 scanner := bufio .NewScanner (f )
236306 scanner .Buffer (make ([]byte , 0 , 4 * 1024 ), 1024 * 1024 )
307+ lineNo := 0
237308 for scanner .Scan () {
309+ lineNo ++
238310 raw := scanner .Bytes ()
239311 if len (raw ) == 0 {
240312 continue
241313 }
242314 var j jsonSpendRecord
243315 if err := json .Unmarshal (raw , & j ); err != nil {
244- // Skip malformed lines — a single bad write shouldn't
245- // brick the whole log.
246- continue
316+ // Fail closed: a corrupt/garbage line is never dropped.
317+ return nil , nil , false , fmt .Errorf ("malformed cap-state line %d: %w" , lineNo , err )
318+ }
319+ if j .HMAC != "" {
320+ sawHMAC = true
321+ } else {
322+ sawPlain = true
323+ }
324+ if hmacKey != nil && j .HMAC != "" {
325+ want := recordHMAC (hmacKey , prev , j )
326+ got , decErr := base64 .StdEncoding .DecodeString (j .HMAC )
327+ if decErr != nil || ! hmac .Equal (want , got ) {
328+ return nil , nil , false , fmt .Errorf ("cap-state line %d: HMAC mismatch — spend log tampered with or truncated" , lineNo )
329+ }
330+ prev = got
247331 }
248332 out = append (out , spendRecord {at : j .At , asset : j .Asset , amount : j .Amount })
249333 }
250334 if err := scanner .Err (); err != nil {
251- return out , err
335+ return nil , nil , false , err
336+ }
337+ if hmacKey != nil {
338+ switch {
339+ case sawHMAC && sawPlain :
340+ // A file that mixes authenticated and unauthenticated
341+ // records can only arise from tampering (e.g. an attacker
342+ // appended a plain record to a signed chain). Refuse.
343+ return nil , nil , false , fmt .Errorf ("cap-state %s mixes authenticated and unauthenticated records — refusing (possible tampering)" , path )
344+ case sawPlain && ! sawHMAC :
345+ // All-legacy file: migrate it to an authenticated chain.
346+ return out , nil , true , nil
347+ }
348+ }
349+ return out , prev , false , nil
350+ }
351+
352+ // rewriteSpendRecords atomically replaces the cap-state file with an
353+ // authenticated HMAC chain over recs. Used by the legacy→authenticated
354+ // migration. Writes to a temp file in the same dir then renames, so a
355+ // crash never leaves a half-written log. Returns the new chain tip.
356+ func rewriteSpendRecords (path string , recs []spendRecord , hmacKey []byte ) ([]byte , error ) {
357+ dir := filepath .Dir (path )
358+ tmp , err := os .CreateTemp (dir , ".cap-state-*.tmp" )
359+ if err != nil {
360+ return nil , err
361+ }
362+ tmpName := tmp .Name ()
363+ defer os .Remove (tmpName ) // no-op after a successful rename
364+ if err := tmp .Chmod (0o600 ); err != nil {
365+ _ = tmp .Close ()
366+ return nil , err
367+ }
368+ var prev []byte
369+ w := bufio .NewWriter (tmp )
370+ for _ , r := range recs {
371+ j := jsonSpendRecord {At : r .at , Asset : r .asset , Amount : r .amount }
372+ mac := recordHMAC (hmacKey , prev , j )
373+ j .HMAC = base64 .StdEncoding .EncodeToString (mac )
374+ body , err := json .Marshal (j )
375+ if err != nil {
376+ _ = tmp .Close ()
377+ return nil , err
378+ }
379+ if _ , err := w .Write (append (body , '\n' )); err != nil {
380+ _ = tmp .Close ()
381+ return nil , err
382+ }
383+ prev = mac
384+ }
385+ if err := w .Flush (); err != nil {
386+ _ = tmp .Close ()
387+ return nil , err
388+ }
389+ if err := tmp .Sync (); err != nil {
390+ _ = tmp .Close ()
391+ return nil , err
252392 }
253- return out , nil
393+ if err := tmp .Close (); err != nil {
394+ return nil , err
395+ }
396+ if err := os .Rename (tmpName , path ); err != nil {
397+ return nil , err
398+ }
399+ return prev , nil
254400}
255401
256402// appendSpendRecord writes one JSONL line atomically (O_APPEND on
257403// POSIX is sufficient for small writes < PIPE_BUF on the same fd;
258404// we close immediately to avoid fd accumulation on long-running
259405// wallets that haven't paid for a while). 0600 perm matches the
260- // identity file's threat model.
261- func appendSpendRecord (path string , r spendRecord ) error {
262- body , err := json .Marshal (jsonSpendRecord {At : r .at , Asset : r .asset , Amount : r .amount })
406+ // identity file's threat model. When hmacKey is non-nil the record
407+ // carries a chained HMAC linking it to prevHMAC; the new chain tip is
408+ // returned so the caller can extend the chain on the next append.
409+ func appendSpendRecord (path string , r spendRecord , hmacKey , prevHMAC []byte ) ([]byte , error ) {
410+ j := jsonSpendRecord {At : r .at , Asset : r .asset , Amount : r .amount }
411+ var tip []byte
412+ if hmacKey != nil {
413+ tip = recordHMAC (hmacKey , prevHMAC , j )
414+ j .HMAC = base64 .StdEncoding .EncodeToString (tip )
415+ }
416+ body , err := json .Marshal (j )
263417 if err != nil {
264- return err
418+ return nil , err
265419 }
266420 body = append (body , '\n' )
267421 f , err := os .OpenFile (path , os .O_CREATE | os .O_WRONLY | os .O_APPEND , 0o600 )
268422 if err != nil {
269- return err
423+ return nil , err
270424 }
271425 defer f .Close ()
272426 if _ , err := f .Write (body ); err != nil {
273- return err
427+ return nil , err
428+ }
429+ if err := f .Sync (); err != nil {
430+ return nil , err
274431 }
275- return f . Sync ()
432+ return tip , nil
276433}
277434
278435// capMu, caps, and spendLog live on Wallet but are declared here so
0 commit comments