Skip to content

Commit 92cf9ba

Browse files
committed
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.
1 parent f4bdcae commit 92cf9ba

5 files changed

Lines changed: 450 additions & 37 deletions

File tree

cmd/wallet/main.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,20 @@ func run(ctx context.Context, args []string) error {
171171
// post-startup Pay. Without this, a daemon restart silently
172172
// resets the cap counter — a hard bypass.
173173
if *capState != "" {
174-
if err := w.UseCapStateFile(*capState); err != nil {
174+
// Derive a cap-state HMAC key from the wallet identity so the
175+
// spend log is tamper-evident: the same OS user can't erase or
176+
// alter spend history to bypass caps without breaking the chain.
177+
// Falls back to the legacy unauthenticated format only if the
178+
// signer can't yield a key (non-LocalSigner runtime signers).
179+
hmacKey := signer.DeriveCapStateHMACKey()
180+
if err := w.UseCapStateFileWithHMAC(*capState, hmacKey); err != nil {
175181
return fmt.Errorf("cap-state: %w", err)
176182
}
177-
logger.Printf("cap-state: persisting to %s", *capState)
183+
if hmacKey != nil {
184+
logger.Printf("cap-state: persisting to %s (HMAC-authenticated)", *capState)
185+
} else {
186+
logger.Printf("cap-state: persisting to %s (legacy unauthenticated; no identity key)", *capState)
187+
}
178188
}
179189

180190
// Activate manifest-declared spend caps. The supervisor lays the

pkg/wallet/signer.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package wallet
22

33
import (
44
"crypto/ed25519"
5+
"crypto/hmac"
56
"crypto/rand"
7+
"crypto/sha256"
68
"encoding/hex"
79
"encoding/json"
810
"errors"
@@ -48,6 +50,35 @@ func (s *LocalSigner) Sign(msg []byte) ([]byte, error) {
4850
return ed25519.Sign(s.priv, msg), nil
4951
}
5052

53+
// DeriveCapStateHMACKey derives a 32-byte HMAC-SHA256 key from the
54+
// signer's Ed25519 private key using HKDF with info="pilot-cap-state-v1".
55+
// The key authenticates the wallet's cap-state spend log so the same OS
56+
// user can't tamper with or erase it to bypass spend caps. Returns nil
57+
// if the private key is empty.
58+
//
59+
// Keep info in sync with the reader (pilotctl appstore caps): both must
60+
// derive the identical key from the same identity, since the daemon
61+
// reads what the wallet writes.
62+
func (s *LocalSigner) DeriveCapStateHMACKey() []byte {
63+
return deriveCapStateHMACKey(s.priv)
64+
}
65+
66+
// deriveCapStateHMACKey is the HKDF body shared by DeriveCapStateHMACKey.
67+
func deriveCapStateHMACKey(priv ed25519.PrivateKey) []byte {
68+
if len(priv) == 0 {
69+
return nil
70+
}
71+
// HKDF-Extract: PRK = HMAC-SHA256(salt=nil, IKM=privateKey)
72+
mac := hmac.New(sha256.New, nil)
73+
mac.Write(priv)
74+
prk := mac.Sum(nil)
75+
// HKDF-Expand: OKM = HMAC-SHA256(PRK, info || 0x01)
76+
mac = hmac.New(sha256.New, prk)
77+
mac.Write([]byte("pilot-cap-state-v1"))
78+
mac.Write([]byte{0x01})
79+
return mac.Sum(nil)
80+
}
81+
5182
// identityFile is the on-disk shape of a persisted signer. The seed
5283
// regenerates both halves of the ed25519 keypair so storing it alone is
5384
// sufficient; the pubkey field is for human inspection.

pkg/wallet/spendcap.go

Lines changed: 188 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ package wallet
22

33
import (
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.
177192
type 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.
195226
func (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

Comments
 (0)