Skip to content

signerd: reserve daily-cap before signing + durable rollback-resistant accounting (C3) #108

Description

@hellno

Severity: MEDIUM (durability/correctness; a testnet-confined security side-benefit). Source: ADR 0003 (#105), decoupled by ADR 0004 (#119). Reviewed + re-scoped via /autoplan (2026-06-20).

Problem

The signer daily spend cap is not durable: spent_today_wei is in-memory and force-zeroed on every daemon load (policy_store.rs:63), incremented only post-broadcast (daemon.rs:1166, after the broadcast at :1148). So any restart — crash, OOM, app update, sleep — silently zeroes the day's accounting. That is a plain correctness bug that hits honest users on every restart.

It also removes a (now testnet-confined) drain: a same-uid attacker could crash-loop the auto-respawning daemon (supervise.rs) to reset the cap and spend in within-cap chunks. This is bounded — the daemon boots Locked and needs a fresh passphrase per restart (daemon.rs:262), and #76's guardrail already downgrades every real-chain auto-allow to a human card (daemon.rs:635, guardrail_active :1392), so the hands-free within-cap path only exists on testnets/forks or under an explicit autonomy override. The same-uid attacker can also just delete the counter file (accepted residual, ADR 0004), so the durable counter's value is primarily honest-restart durability, not a security guarantee. The security story completes when #72 authenticates the cap values in policy.json.

Re-scoped by review: dropped the nonce-keyed RPC reconciliation from the earlier draft (it restructures the frozen broadcast path and defends a sub-second window the file-delete attacker bypasses). Conservative count-on-reboot gives the same safety, errs tighter, and needs no chain query.

Acceptance criteria (Option A)

  • Durable counter file (spend.json in config_dir) holding {chain_id, account, day, committed_wei, reserved_wei}, written atomically via a new deckard_core::atomic_write(path, &[u8]) extracted from Vault::write_atomic (temp@0600 + fsync + rename + dir-sync). (F1 — write_atomic is a Vault method, not a byte-writer; the extraction is a prerequisite.)
  • Reserve-before-sign: in execute, after the cap re-check (daemon.rs:1116) and before the broadcast (:1140), durably record reserved_wei += value (skip when value == 0 — shields/approves/contract-calls). A reserve-write failure denies fail-closed with a new reserve_failed deny tag. (F3, F5)
  • Effective spend = committed_wei + reserved_wei. On reboot any leftover reserved_wei (a crash between reserve and commit) is counted as spent — never released by a chain query. (conservative count-on-reboot)
  • Broadcast outcome: success → commit (reserved→committed); clean RPC rejection → release the reserve; timeout → KEEP it counted (a timed-out tx may have landed — must not re-open the double-spend). (F4)
  • Forward-only rollover: one effective_day = max(current_utc_day(), stored_day); reset spend only when the day advances. A backward wall-clock does not reset (fail closed). Replaces the bidirectional rollover() (daemon.rs:1397). (F8)
  • Binding: validate chain_id at boot (mismatch → discard — a different chain is a different cap); validate account at unlock (mismatch → reset window). Missing file → fresh window; corrupt file → fully-spent + loud log until rollover (mirror the policy loader's missing/invalid split). Clamp recovered spend ≤ daily cap. (F9, F10, F6)
  • Supervisor crash-budget (Option D): a non-resetting N-crashes-in-M-minutes budget in monitor_loop stops respawning and surfaces a loud error (the per-spawn backoff resets every 200ms today — supervise.rs:590 — so a crash-loop respawns at the floor forever). Counter-load failures are non-fatal so a single bad file can't feed the budget into a lockout. (F11)
  • Tests: unit (round-trip; missing→fresh / corrupt→fully-spent; forward-only rollover incl. clock-rewind; value==0 skips reserve; chain/account binding; reserve-error fail-closed; clamp) + anvil e2e (happy; fault-injected crash-restart; clean-Err release; timeout keep; honest-restart durability) + supervisor budget + isolated DECKARD_CONFIG_DIR per test (cross-test counter bleed). (F12)

Deferred

Metadata

Metadata

Assignees

No one assigned

    Labels

    securitySecurity-relevant

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions