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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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