Skip to content

Security: dwan-ith/accural

Security

SECURITY.md

Security Notes & Threat Model

One policy engine

Spend rules live in exactly one TypeScript implementation (src/policy.ts, evaluateSpend) consumed by the control plane (protocol.ts), planner pre-checks (agents/run.ts), and any future control surface. The engine mirrors the chain programs rule-for-rule, including evaluation order (zero-amount → cap → budget → approval), lazy rolling-window semantics with anchor advancement, and the 8-entry blocklist bound. Local control-plane escrows carry deadlines copied from their intents and unwind via a permissionless deadline refund (POST /escrows/:taskId/expire-refund), matching expire_refund_escrow on-chain. Chain programs remain independent implementations by necessity (Rust/Solidity); conformance is pinned by unit vectors on this module plus on-chain unit tests of their own helpers.

Design invariants

  1. No unsupervised spend above policy. Chain rails enforce, atomically on-chain: allowed-action bit, per-transaction cap, rolling session budget, recipient blocklist, and approval threshold. The threshold can only be bypassed with a real registry-owner signature (Solana: owner as main authority or first remaining_accounts entry; EVM: caller is owner, or a single-use amount-bounded grantSpendApproval is consumed).
  2. Delegates never touch owner wallets. Delegated sources are the delegate's own token account or the program-owned treasury. Owner wallets move only under owner signatures.
  3. Escrow funds have exactly two exits: to the beneficiary (verifier-signed; verifier ≠ owner ≠ beneficiary enforced at funding) or back to the exact original funding source (owner refund, or permissionless refund after expires_at).
  4. Canonical accounts everywhere — escrow ATAs must equal the canonical derivation; delegate records must live at canonical PDAs / mapping keys; refund destinations pinned by escrow.payer.
  5. Deterministic reconciliation hashes via code-point-ordered canonical JSON.
  6. PCI boundary: raw card data is structurally rejected (number/pan/cvv* body keys); card rails operate exclusively on processor tokens; brand detection exists for routing labels, not for accepting PANs.
  7. Settlement requires captured funds. Fiat release endpoints observe provider state and refuse (400) unless the payment is authorized (captured now) or already captured. An intent that never completed checkout cannot settle a task — no reputation, no ledger release.
  8. Commitment-bound verification. Intents may pin an expected 32-byte semantic hash at request time (reconciliationCommitment). When pinned, release_escrow (Solana), releaseEscrow (EVM), and the verifier-driven fiat release all reject any other hash — making verification evidence non-malleable after the task is scoped. Zero commitment preserves open verification; webhook-driven releases are hash-exempt because capture itself is the provider's attestation.

Rail-specific security properties

Solana (Anchor program)

  • Treasury sweeps are owner-only and destination-constrained (withdraw_treasury).
  • Blocklist applies to intents AND spends (request-time parity added).
  • All transfers use transfer_checked with stored decimals; works with Token-2022.

Ethereum (AccuralCore.sol)

  • nonReentrant on all value-moving functions; checks-effects-interactions throughout.
  • Safe ERC-20 handling tolerates missing return values (USDT-style) via returndata-size checks.
  • Verifier designation is two-step (designateVerifier) and self-dealing combinations revert.
  • Above-threshold delegate spends consume single-use owner approvals bounded by amount + time.

Stripe (cards)

  • Escrow = PaymentIntent with capture_method=manual: funds are held, not charged; release = capture; pre-capture unwind = cancel; post-capture unwind = refund.
  • Webhooks verified via HMAC-SHA256 over ${timestamp}.${rawBody} with 300s replay tolerance and constant-time comparison.
  • Dashboard-initiated captures are trusted-operator events; by default they only RECORD the capture (PAYMENT_CAPTURED) and the escrow stays FUNDED until the verifier releases via Accural. Operators may opt into auto-settlement with ACCURAL_FIAT_AUTO_RELEASE_ON_CAPTURE=true.

Razorpay (UPI)

  • UPI has no merchant-side authorization hold, and collects capture automatically. Therefore the verifier gate lives in Accural, not the network: a payment.captured webhook records the settlement but leaves the escrow FUNDED pending verifier release (or the explicit auto-release opt-in above). Refunds unwind through /rails/razorpay/refund.
  • Webhooks verified via HMAC-SHA256 of the raw body against RAZORPAY_WEBHOOK_SECRET.

Backend hardening

  • Bearer token (constant-time compare) required on mutating routes when ACCURAL_API_TOKEN is set; /webhooks/* are exempt because they authenticate via provider HMACs.
  • Bundle execution fails closed without credentials; allowlisted programs only; simulate-first.
  • Request bodies capped (default 1 MB); loopback bind by default; loud warning when signing keys exist without an API token.

Ledger integrity & idempotency

  • Every reconciliation record stores the SHA-256 of its canonical JSON payload. verifyLedger (exposed at GET /ledger/verify) recomputes all hashes from stored inputs; any post-hoc edit of amount/outcome/purpose/policy snapshot breaks the chain and is reported with both hashes.
  • Provider webhooks are treated as at-least-once: capture events are deduplicated per task (PAYMENT_CAPTURED recorded once), auto-released escrows answer redelivery with 200 already-released, and refund endpoints observe provider state before acting so a repeated call cannot double-refund.

Known limitations (honest list)

  • FX is out of scope. Fiat charges occur in native currency (INR paise, USD cents); policy budgets are USDC-denominated. Callers pass budgetAmount explicitly for fiat flows — operators own conversion. Do not assume 1:1.
  • Reputation Sybil bounds: self-dealing triples are blocked, but many-registries farming is possible; reputation is informational, not a trust root. total_volume_minor mixes mints.
  • Session windows are lazy — reset happens at next spend after expiry, not on wall clock alone.
  • Blocked recipients capped at 8 (fixed-size accounts).
  • Token-2022 extension behaviors (transfer fees, confidential transfers) are not compensated.
  • Bundle phases are separate transactions; partial progress leaves monotone, safe state but callers must handle continuation.
  • EVM contract is unaudited; treat as reference implementation. No native-ETH settlement in v1.
  • Webhook idempotency: providers may redeliver; release paths are idempotent by status checks, but operators should deduplicate by event id at the edge for strict once-only semantics.

There aren't any published security advisories