feat: freedom co-signer policy gate (stage 1, shadow mode) - #583
feat: freedom co-signer policy gate (stage 1, shadow mode)#583gomesalexandre wants to merge 5 commits into
Conversation
Adds POST /freedom/sign — a new signing surface authenticated via user
JWT (VaultAuthMiddleware, same as /auth/me) that gates outbound txs
through a static EVM policy before handing off to the existing DKLS
keysign pipeline.
New files:
- internal/api/freedom_policy.go — FreedomPolicy (DENY + ALLOW rules)
- internal/api/freedom.go — FreedomSign handler
- internal/api/freedom_test.go — 5 gate tests (all pass, no infra needed)
Route: POST /freedom/sign (VaultAuthMiddleware)
FreedomPolicy caps (Ethereum mainnet):
- DENY: USDC approve(*, uint256.max) — blocks unbounded approval drains
- ALLOW: native ETH transfer MAX 0.01 ETH, any recipient
- ALLOW: USDC ERC-20 transfer MAX $10 (1e7 units), any recipient
- ALLOW: USDC ERC-20 approve MAX uint256.max-1, any spender
Shadow mode (cfg.Freedom.Enforce=false, default):
policy misses are logged but signing proceeds — safe zero-downtime ramp.
Enforcement mode (cfg.Freedom.Enforce=true, opt-in / used in tests):
policy miss → 403 Forbidden.
Hash derivation mirrors validateAndSign: verifier derives hashes from
txBytes independently, ignoring any client-provided hash values.
Depends on vultisig/recipes feat/freedom-deny-support for:
- DENY-wins semantics in engine.Evaluate
- TARGET_TYPE_UNSPECIFIED ("any recipient") in EVM assertTarget
- Calldata guard in assertArgsNative (prevents contract-call txs from
matching the native-transfer amount cap)
Also bumps bytedance/sonic v1.14.2→v1.15.2 (pre-existing Go 1.26 build
breakage — sonic v1.14.2 has an undefined GoMapIterator symbol on Go
1.26 that blocked all test runs in this repo).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
…orce supportedFreedomChains() was declared but never called. Without the guard, any non-Ethereum chain (Solana, BTC, THORChain, non-ETH EVM) reached policy evaluation with zero matching rules. In shadow mode "no matching rule" is a pass → those chains would be co-signed with no policy check at all. Reject unsupported chains with 400 immediately after firstMsg is bound, before the engine is even created. Fail-closed in both modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
… drain allow-usdc-approve had MaxValue = uint256.max-1. A compromised agent could issue approve(drainer, ~∞) on the user's USDC and then transferFrom the full balance — bypassing the co-signer entirely. The DENY rule only caught the exact uint256.max boundary, not uint256.max-1. Cap the approve to 10_000_000 (i.e. \$10 USDC, 6 decimals) — same envelope as the transfer rule. The aave deposit bundle uses exact-amount approves so \$10 covers every legitimate freedom flow while bounding blast radius to \$10. The DENY-uint256.max rule is kept as belt-and-suspenders. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
Without this check, a valid JWT for a vault that has not yet been onboarded would successfully enqueue a keysign task. The worker would then fail to execute it (vault file not found), wasting a signing slot and creating a polluted tx indexer entry. Mirror the plugin.go validateAndSign existence gate: 404 if the freedom vault backup file is absent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
…guard tests Three new test surfaces: TestFreedomChainAllowlist — verifies supportedFreedomChains() excludes Solana, Bitcoin, BSC, and that the engine also errors on those chains (no matching rules). Ensures the P1-1 HTTP handler guard has a matching engine-level check. P1-2 regression: near-infinite approve (uint256.max-1) must be DENIED after tightening allow-usdc-approve to \$10. Would have passed before the fix. Also: \$5 USDC approve must still pass (positive path for deposit bundles). P1-3 calldata guard lock-in: ETH value + ERC-20 calldata must NOT pass the native-transfer rule. Guards vultisig-recipes assertArgsNative (commit 60491d1). If that guard regresses, this test fails and blocks the deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFkUyPvR2WuDfThGiVYLke
Adversarial fund-safety review → fixedRan a dedicated adversarial review of this gate (the signing-path control). It confirmed the core is sound — hash-from-bytes derivation overrides the client hash unconditionally (the "bytes X, signed hash Y" attack is closed), DENY-wins is correct, JWT vault-key binding is correct, enforce mode is fail-closed everywhere. It found 3 P1 blockers + P2s, all now fixed + tested:
P2-3 (per-vault daily spend cap) + P2-4 (any-recipient) are documented as pre-enforcement design TODOs in the "known limitations" section. With these, the gate is Stage-1 (shadow) sound and ready to progress toward capped enforcement once the daily-cap decision is made. (Codex second-pass still worth running before flipping enforce.) |
|
Closing: the current stack signs freedom txs on-device via the existing 2-of-2 (validateAndSign / plugin-signer), not through a server-side co-signer.
Superseded by that model. Reopenable if we later decide we want a server-side policy co-signer (defense-in-depth, Stage-4). recipes #596 (deny-wins/calldata-guard) left open pending a separate call on its standalone value to the recipes engine. |
Depends on vultisig/recipes#596.
what
Adds
POST /freedom/sign— a new signing surface for the agent's code-as-action feature. Users authenticate via JWT (sameVaultAuthMiddlewareas/auth/me) and submit EVM tx proposals. The verifier:supportedFreedomChains()with 400 (fail-closed, shadow + enforce)FreedomPolicyagainst the tx bytesfreedom.enforce=true): 403 on policy missvalidateAndSign)txIndexerService, enqueuesTypeKeySignDKLSFreedomPolicy caps (Ethereum mainnet)
approve(*, uint256.max)on USDC blockedGate tests (
internal/api/freedom_test.go) — all 14 passExisting (5 cases):
allow-usdc-transferruleNew (9 cases in 2 functions):
TestFreedomChainAllowlist: Solana, Bitcoin, BSC not in allowlist + engine errors on them (no matching rules)uint256.max-1USDC approve → DENIED (would have passed before the cap fix)assertArgsNativefrom recipes commit 60491d1)Also
config.FreedomConfig{Enforce bool}added toVerifierConfigbytedance/sonicv1.14.2→v1.15.2 (pre-existing Go 1.26 build breakage; sonic v1.14.2 has an undefinedGoMapIteratorsymbol that blocked all test runs)known limitations before enforcement
These are design decisions, not bugs — both must be addressed before flipping
freedom.enforce=truein production:INCRBY, 24h TTL) should be added as a pre-enforcement TODO.TARGET_TYPE_UNSPECIFIEDon native ETH = any recipient. Theallow-eth-transferrule caps amount but not destination. A compromised agent can send 0.01 ETH to any address repeatedly. This is intentional for the current UX (no whitelist), but means the blast radius for native ETH is unbounded in total (amount-per-tx is bounded). A recipient allowlist or daily accumulator mitigates this.open questions / stage 2 todos
GetVaultBackupFilename(pubKey, "vultisig.freedom"); users need a freedom vault share installed before signing works end-to-endrisk
Low in shadow mode (default). Do NOT flip
freedom.enforce=truewithout first merging vultisig/recipes#596 and the two pre-enforcement TODOs above.🤖 Agent-generated