feat(signerd): deckard-signerd — process-isolated signer daemon + policy gate (Closes #4) - #6
Merged
Merged
Conversation
Pin the real Helios API against a16z/helios@0.11.1 (depend on helios-ethereum, git-only; alloy unifies to one alloy-primitives 1.6.0), prove the walkaway beat on mainnet, and rewrite 20-helios-sidecar.md with measured numbers, the failover design, provider/privacy findings, and an app-integration section. spikes/helios-walkaway/ (standalone crate) embeds Helios, serves a verified mainnet balance, and headless-PASSes two scenarios: - availability: cut the primary EL on camera, keep serving verified reads via a second EL (the cut primary's own head still returns from the CL cache, proving head is EL-independent) - integrity: point at a lying RPC (tampered eth_getProof balance) and refuse the read (invalid account proof) where a centralized wallet would display the lie Measured (M-series, mainnet): cold ~11s, warm ~2s, cut->failover <=1 block. CL finding: only Nimbus + dRPC actually drive a Helios sync (Lodestar/PublicNode return 200 but fail). Decisions: Kurtosis deferred off the v1 critical path; public CLs for the hero. Cross-doc notes added to 00/30/README. Reviewed by a Codex adversarial pass: all API claims confirmed against source; overclaims scoped and two spike bugs fixed (fake EL-independent head proof; lie scenario now asserts a proof rejection specifically).
…less #3) Self-contained brief a parallel agent can run to prove/disprove that Helios's localhost JSON-RPC server satisfies Kohaku railgun's IntoEip1193Provider and serves every method the read/sync path calls. Standalone, read-only, does not touch the app crates.
…icy gate (#4) The operator spine: a separate process owns the decrypted key, runs the real policy gate, signs + broadcasts Sends, and answers STOP. The app (and the future deckard-mcp) become key-less clients over a same-uid UDS. Contract (extends the frozen wire, in lockstep with MockSigner + 30-mcp-shape.md): - SignerRequest += Unlock{passphrase}/Lock/Resolve{request_id,approved}; SignerResponse += Unlock(UnlockOutcome{Unlocked{address}|BadPassphrase|NoVault}). - New pure policy::evaluate(&Intent,&Policy)->Decision — the ONE decision function; MockSigner now calls it (no duplicated logic; parity is unit-asserted). deckard-signerd (AGPL-3.0-or-later), lib + bin: - UDS server: length-delimited CBOR frames (4-byte BE len, max 1 MiB); SO_PEERCRED/ LOCAL_PEERCRED same-uid auth; socket 0600 in a 0700 dir; single-instance flock. - Locked<->Unlocked{vault} state machine over deckard-core's keystore (reused, not rebuilt). Unlock decrypts + holds the key (passphrase moved into Zeroizing, frame scrubbed, never logged); Lock/RevokeAll zeroize -> Locked + deny in-flight; re-unlock re-arms with a clean session. - Send-only propose/execute: process pre-checks (locked/chain_mismatch/unsupported_v1/ erc20) then evaluate; EIP-1559 sign + broadcast via config RPC; deterministic keccak(intent) request id so an Allow is executable; Resolve approval loop; TTL. - TOCTOU + spend guards at sign time: STOP denies a pre-approved request; an auto-allow is re-capped against current spend (daily cap can't be bypassed by batching); a human-approved overage is honored; Pending AND Allowed expire; broadcast is bounded by a timeout so a hung RPC can't wedge the daemon. Re-propose is idempotent. - Signer version bridge: extract the version-stable B256 scalar from core's alloy-signer-local 2.0.5 signer and reconstruct it in the daemon's 1.8.3 alloy stack. App: spawns + supervises the daemon (restart-on-crash, kill-on-exit), unlocks OVER the socket (no in-process UnlockedVault/PrivateKeySigner for signing); onboarding still seals + writes the vault, then unlocks via the daemon. Shared config dir resolver in deckard-core so app + daemon never drift. Tests (75 workspace; anvil broadcast tests skip if foundry absent, run in CI): unlock outcomes, propose matrix, off-allowlist, resolve/TTL, STOP zeroize + re-arm, TOCTOU, socket perms, mock<->evaluate parity, peer-cred check, passphrase Zeroizing, + anvil-fork sign/broadcast/receipt and the daily-cap-at-execute regression. Closes #4
…gun's EIP-1193 provider (T-Trustless #3) Proves the v1 seam end to end (against kohaku@618c53f): Helios's localhost JSON-RPC server (.rpc_address) is the EIP-1193 provider Railgun reads through. - IntoEip1193Provider is Kohaku's OWN 7-method trait (eip-1193-provider crate); an alloy DynProvider satisfies it via Kohaku's shipped Alloy adapter — no custom adapter for v1. Read/sync path = exactly eth_blockNumber + eth_getLogs + eth_call, all in Helios's served set. - One required fix: alloy's Provider::call defaults to the `pending` tag, which Helios (light client) can't serve; build the provider with ProviderBuilder::with_default_block(BlockId::latest()). One line, Deckard-side. - Tier-2 (default) drives the adapter + logs methods via a pass-through proxy; Tier-1 (--features railgun) links the full railgun ZK crate and drives the real RpcSyncer/RailgunBuilder through Helios (414 SyncEvents). railgun compiles standalone (retires 10's R1c). Loopback hop ~0.3 ms/call (release). Findings written into 20-helios-sidecar.md (Integration + Measured + open-questions).
…erd (#1+#2) Route both raw-RPC read paths through an embedded Helios light client and tag every read with a trust label. #1 deckard-contract: new ReadStatus { Verified | Degraded{reason} | Unsynced{reason} } + a read_status field on BalanceReport (round-trips JSON + CBOR). #2 deckard-core: new helios.rs launcher (Helios localhost JSON-RPC server + the required with_default_block(latest) consumer-provider fix); EthProvider reads now go through it and return Read<T>{ value, status }. deckard-signerd: read_balance + the daemon Balance handler route through Helios the same way. App status line surfaces the tag (verified / NOT VERIFIED). The heavy helios-ethereum dep is gated behind a default-on `verified-reads` feature with an honest raw-RPC fallback: never claims Verified without a fresh Helios-backed read (locked / Helios-down / feature-off all map to Unsynced; removed the old unwrap_or(ZERO)-as-truth). v1 runs two independent Helios instances (app + daemon); the failover supervisor and reads-consolidation are deferred (// TODO post-v1). Adversarial review (Codex invoked + manual pass) fixed one P1: the daemon held its global mutex across the multi-second Helios bootstrap, which could block the STOP/Lock brake — Helios moved into an off-lock HeliosCell. Plus 3 P2 honesty fixes (timestamp-based head freshness vs 60s, value-then-status ordering, no_std doc). Verified green with the real cargo (rtk cache bypassed): build verified-reads ON+OFF, deckard-app compiles; tests — signerd 15 lib + 9 daemon_e2e + 1 parity + 3 anvil_e2e (STOP/zeroize + TOCTOU intact), contract 32, core 13.
Standalone spike (spikes/shield-railgun/) ports kohaku's transact_utxo.rs (rev 618c53f) against an anvil Sepolia fork @ 10822990 through a plain alloy provider, and runs the full shield→sync→balance→transfer→unshield with the EXACT upstream numeric asserts — all GREEN from our own dep edge: shield 997_500 · shield_native 1_097_250 · transfer 1_092_250/5_000 · unshield 1_091_250/5_000 + EOA WETH 998. (independently re-run, 1 passed, 32.8s) R1d answered (the "is instant auto-shield honest?" question): YES, and for the right reason. Railgun SHIELD does NO client ZK proof — ~2–13 ms (note-encrypt + ABI-encode); the contract verifies the commitment on-chain. Groth16 proving lives entirely on the SPEND: transfer ~11.7s / unshield ~9.6s cold-debug, ~halved by the `parallel` feature (~5.4 / ~4.3s). So "instant shield, prove-on-spend" is a faithful UX; the slow path (unshield) is off-camera fast-follow. Retires R1c (railgun standalone-consumable — full crate links + runs from our edge). Gotchas captured: the `railgun/testing` feature is required for external consumers (gates SubsquidSyncer::with_latest_block); `.with_poi()` constructs but credits None on a local fork (defaults to no-POI, WITH_POI=1 opt-in). Adversarial review (Codex + manual) caught + fixed a P1: two SHIELD timers were mislabeled "proof build" when shields do no proving — relabeled, R1d framing corrected. Asserts verified genuine (hard assert_eq! vs upstream constants, not weakened). Doc 10-kohaku-shield.md updated (status + R1d/proving resolved).
…box anvil test (#1) Beat-2 shield is now a first-class write in Deckard's own path. #1a deckard-core: `shield::build_shield_native_intent(chain_id, recipient, value) -> Intent{kind:Shield, to, value, calldata}` — KEY-LESS (a deposit needs only the recipient 0zk address, no spending key), behind a default-on `shield` Cargo feature. Feature-off compiles without railgun and returns an honest "shield unavailable" error. #1b deckard-signerd: `broadcast_intent` carries calldata (Shield/ContractCall); the execute path broadcasts Intent calldata+value+to. Native sends stay byte-identical (broadcast_native_send is now a thin &[] wrapper). The daemon stays ZK-free — it only signs+broadcasts the bytes it's handed. #1c crates/deckard-signerd/tests/shield_e2e.rs: a repeatable BLACK-BOX integration test (#[ignore], fresh anvil Sepolia fork @ 10822990) that drives Deckard's path end to end (core builder -> daemon propose/execute -> on-chain -> railgun sync) and hard-asserts the privacy property: private +997_500 (exact 25bps on 1_000_000), public down by value+gas. shield-only (fast, no proving/artifacts). Adversarial review (Codex + manual) fixed two P1s: - signerd `shield` feature was leaky (deckard-core dep lacked default-features=false, so the off-switch didn't drop railgun) -> fixed; railgun now absent from signerd's normal tree when off. - SECURITY: `Intent{kind:Shield, calldata: empty}` would have broadcast as a plain native send to an arbitrary `to` under the "Shield" label -> `calldata_ok` now requires non-empty calldata for Shield/Unshield/ContractCall (Decision::Deny{undecodable}); daemon + contract tests cover it. vendor/eip-1193-provider: a native-only fork of kohaku's eip-1193-provider (rev 618c53f, verbatim .rs, `js` dropped) + a workspace [patch]. Required because upstream's `js` feature pins wasm-bindgen=0.2.108 exact while the GPUI app's web-sys pins 0.2.122 exact — irreconcilable in one workspace; `js` is wasm32-only and unused. Maintenance + license (no upstream license field, like railgun) to revisit before ship. Verified GREEN with real cargo (pinned 1.95.0), rtk cache bypassed: build default + feature-off (railgun absent) + whole workspace incl. GPUI app; daemon_e2e 9/9 + parity 1/1 (STOP/zeroize + TOCTOU + Shield-Allow/empty-Shield-Deny intact); deckard-contract 32+1; shield_e2e re-run by me on a fresh fork (private +997500). Deferred (// TODO post-v1): receive-watcher, MCP, railgun key-derivation for balance-display, production HeliosEip1193, Unshield/ContractCall (Deny v1).
…faces Tracking was scattered across 5 mostly-stale surfaces (root README "Status — v0", docs/build/README "spec ✓" table, specs/SPEC-v0-epic "pre-implementation", specs/HANDOFF "Built so far: src/wallet.rs…", roadmap). Consolidate: - NEW STATUS.md (repo root) = single source of truth: v1 demo beats, crates/tracks, spikes, v0 base, open risks — each with status + commit refs. - root README + docs/build/README: status sections now point to STATUS.md and are corrected to current reality (keystore/daemon/verified-reads/shield built, not "plaintext EOA / spec"). - docs/build/README: dropped the drifting per-doc "spec ✓" column (now a spec index), fixed the stale hero-spikes section (R1 proving measured = instant; R2 reads integrated). - specs/SPEC-v0-epic + HANDOFF: one-line "live status → /STATUS.md, fields below are stale" banners so they stop competing as trackers. No code change.
…e hero Independent Codex audit (read-only, high reasoning) over all 4 crates + tests found no lies/stubs/spike-confusion, but that STATUS oversold 'done + tested' for the hero: - shield mechanism is built (core builder + daemon broadcast, confirmed) but shield_e2e is #[ignore] (not default cargo test) and shield is NOT reachable from the app or an agent (no trigger/watcher/MCP) -> downgraded to 🟡. - added a top 'reality check': mechanisms built + security state machine real, but the recordable demo FLOW (receive->shield->walkaway, agent-driven) is unwired end-to-end. - added per-table test caveats (shield_e2e ignored; anvil_e2e skips w/o anvil; core reads mocked; app send uses a fake daemon; STOP/gate tests are real).
The mechanisms are de-risked + built; the gap is reachability + visible state. A) shield on screen (trigger + shielded-balance view — the one new build) B) in-app walkaway (cut control + ReadStatus badge flip) C) receive landing (balance refresh) D) agent spine (manual stand-in or deckard-mcp) E) one continuous take + polish. Critical path: A+B+C, D as narration.
hellno
added a commit
that referenced
this pull request
Jun 10, 2026
feat(signerd): deckard-signerd — process-isolated signer daemon + policy gate (Closes #4)
hellno
pushed a commit
that referenced
this pull request
Jun 14, 2026
Record the 2026-06-14 product decisions in the compatibility map: - Funding: self-funded, no protocol revenue (content for #21 FUNDING.md) - Security audit (#1): decision spike, budget/vendor undecided - Out of scope for v0: social/guardian recovery (#6) and L2 force-withdrawal (#15), documented as deliberate trade-offs No GitHub issues opened (per process decision).
hellno
pushed a commit
that referenced
this pull request
Jun 14, 2026
Record the 2026-06-14 product decisions in the compatibility map: - Funding: self-funded, no protocol revenue (content for #21 FUNDING.md) - Security audit (#1): decision spike, budget/vendor undecided - Out of scope for v0: social/guardian recovery (#6) and L2 force-withdrawal (#15), documented as deliberate trade-offs No GitHub issues opened (per process decision).
hellno
added a commit
that referenced
this pull request
Jun 20, 2026
Codex cross-model adjudication + the adversarial review agree the rollback anchor is mispriced for a testnet alpha whose threat model already concedes same-uid (incl. filesystem write). Vault rollback's entire worst case is reverting a passphrase/KDF rotation; the seed is constant and balances are on-chain; and the mechanism doesn't hold anyway (nothing advances the vault counter; sidecar is replayable; the anchor is same-uid-deletable/rewritable; only a TPM NV counter holds, and macOS has no equivalent). So: defer #71 with blockers B1-B3, build NO cross-trust-domain anchor, and decouple #72/#108 into independent local fixes (policy MAC + fail-closed; durable cap + reserve-before-sign) that never needed it. The "one shared mechanism for three issues" framing was the error; this supersedes ADR 0003 items #4-#6's sequencing. - Rewrite ADR 0004 as a plain Deferred record (no invented vocabulary). - Remove the unwired StateAnchor prototype, its feature flag, and lib wiring. - Reword THREAT-MODEL residual #7 to "accepted residual, deferred". Refs #71 #72 #108. Supersedes ADR 0003 (#105) keystone sequencing.
This was referenced Jun 20, 2026
hellno
added a commit
that referenced
this pull request
Jun 20, 2026
* ADR 0004: rollback-resistant security-state anchor (#71 keystone spike) Spike deliverable for #71: design note answering all five spike questions, the keystone primitive #72/#108 build on, an honest residual, and a small unwired reference implementation behind an off-by-default feature. Method: empirical dep-cost measurement (cargo tree on macOS, diffed vs the workspace lock) + a fan-out research/adversarial-verification pass whose skeptics landed 16 attacks that reshaped the conclusion. Q1 (anchor crate): recommend `keyring` (pinned, per-OS native, default-features off) on macOS/Windows, file-only on Linux; measured cost is +1 crate on macOS, +4 (incl. the libdbus C lib) on Linux where the D-Bus Secret Service is also absent on the headless boxes signerd runs on. NEW DEP — approval-gated, NOT added here. Q2 (correctness): binding the epoch into core_bytes() so both AEAD tags cover it is the only construction that makes the file's epoch un-editable-without-passphrase — but it is a FORMAT_VERSION->2 change that breaks the frozen KAT, so it is specified and deferred, not landed. Check strictly after AEAD verify (no oracle to an unauthenticated caller); key on a re-seal-stable identity, not the per-seal random vault_id. Q3/Q4 (restore + residual): fail-open-with-confirm on the Control capability for testnet alpha; honest that delete-anchor->bootstrap bypasses the gate and that file-only Linux gives ~zero adversarial resistance. THREAT-MODEL residual row #7 added. Q5 (generalization): one StateAnchor over a namespaced record (vault/policy/cap), not a global counter; atomic+fsync+single-writer; the real consumers are #72 (policy version+MAC, fields that must be added first) and #108 (cap reserve-before-sign, with the intra-generation gap and clock-rollback corrected). The standalone vault detector is vacuous today (nothing bumps the vault epoch), so build order is: primitive -> policy/cap -> vault (v2). Prototype: signerd `state_anchor.rs` behind off-by-default `state-anchor`, unwired, zero new deps, 7 tests. DoD green (fmt, `just check` default+tray, `cargo test --workspace`); featured clippy+tests run separately. Refs #71 #72 #108. Relates ADR 0003 (#105). * ADR 0004: pivot to "evaluated, deferred" — drop the anchor + prototype Codex cross-model adjudication + the adversarial review agree the rollback anchor is mispriced for a testnet alpha whose threat model already concedes same-uid (incl. filesystem write). Vault rollback's entire worst case is reverting a passphrase/KDF rotation; the seed is constant and balances are on-chain; and the mechanism doesn't hold anyway (nothing advances the vault counter; sidecar is replayable; the anchor is same-uid-deletable/rewritable; only a TPM NV counter holds, and macOS has no equivalent). So: defer #71 with blockers B1-B3, build NO cross-trust-domain anchor, and decouple #72/#108 into independent local fixes (policy MAC + fail-closed; durable cap + reserve-before-sign) that never needed it. The "one shared mechanism for three issues" framing was the error; this supersedes ADR 0003 items #4-#6's sequencing. - Rewrite ADR 0004 as a plain Deferred record (no invented vocabulary). - Remove the unwired StateAnchor prototype, its feature flag, and lib wiring. - Reword THREAT-MODEL residual #7 to "accepted residual, deferred". Refs #71 #72 #108. Supersedes ADR 0003 (#105) keystone sequencing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Implements #4 —
deckard-signerd, the process-isolated signer daemon (the operator spine). A separate process owns the decrypted key, runs the real policy gate, signs + broadcastsSendtransactions, and answers STOP. The GUI app (and the futuredeckard-mcp) become key-less clients over a same-uid Unix-domain socket.Contract extension (frozen wire, in lockstep with MockSigner +
30-mcp-shape.md)SignerRequest +=Unlock{passphrase}/Lock/Resolve{request_id, approved};SignerResponse += Unlock(UnlockOutcome{Unlocked{address}|BadPassphrase|NoVault}).policy::evaluate(&Intent,&Policy) -> Decision— the one decision function.MockSignernow calls it (no duplicated logic; mock⇄daemon parity is unit-asserted).deckard-signerd(AGPL-3.0-or-later; lib + bin)UnixListener, length-delimited CBOR frames (4-byte BE length + body, max 1 MiB);SO_PEERCRED/LOCAL_PEERCREDsame-uid auth; socket0600in a0700dir; single-instanceflock.Locked⇄Unlocked{vault}overdeckard-core's keystore (reused, not rebuilt). Unlock decrypts + holds the key (passphrase moved intoZeroizing, raw frame scrubbed, never logged);Lock/RevokeAllzeroize →Locked+ deny in-flight; re-unlock re-arms with a clean session.locked/chain_mismatch/unsupported_v1/erc20) → sharedevaluate→ store; EIP-1559 sign + broadcast via config RPC (DECKARD_RPC_URL/DECKARD_CHAIN_ID); deterministickeccak(intent)request id so anAllowis executable;Resolveapproval loop; 120s TTL.PendingandAllowedexpire; broadcast is bounded by a timeout so a hung RPC can't wedge the daemon; re-propose is idempotent.alloy-signer-local2.0.5 signer; the daemon's provider is thealloymeta-crate (1.8.3). Only the version-stableB256scalar crosses — reconstructed into the daemon's stack.App refactor (key-less for signing)
Spawns + supervises the daemon (restart-on-crash backoff, kill-on-exit), unlocks over the socket — no in-process
UnlockedVault/PrivateKeySignerfor signing. Onboarding still seals + writes the vault, then unlocks via the daemon. Shared config-dir resolver indeckard-coreso app + daemon never drift.Acceptance criteria
cargo build --workspace✅; daemonlicense = "AGPL-3.0-or-later"✅0600in a0700dir + a unit test of the peer-cred check ✅Unlocked{address}/ wrong→BadPassphrase/ missing→NoVault✅Allow→Broadcast+ real receipt; over-cap→NeedsApproval; off-allowlist→Deny;Shield→unsupported_v1; chain mismatch→chain_mismatch✅Resolve{true}→Allowed→Broadcast;Resolve{false}/ TTL →Denied✅revoked) + re-unlock re-arms ✅Denied{revoked}✅policy::evaluateparity: identical(Intent, Policy)→ identicalDecision(mock vs daemon) ✅UnlockedVaultfield removed) ✅Zeroizing, never in a response or log line ✅Tests
75 passing workspace-wide. anvil broadcast tests spin a local
anvil(native sends need no fork/secret RPC) and skip gracefully if foundry is absent; CI installsfoundry-toolchainso they run. Regression tests added for all codex P0 findings below.Review
Reviewed with a codex (GPT-5.4 xhigh) adversarial pass. It confirmed the crypto/key-isolation/passphrase/STOP-zeroize core is sound and surfaced 5 issues; the 4 real ones are fixed + regression-tested in this PR:
Allowed→expire_stalenow expiresAllowedtoo.Out of scope (per ticket)
Shield/Railgun (T-Privacy), Helios reads (#20),
deckard-mcp(#30), the native approval-card UI (T-UX), ERC-20 sends,SetPolicy+ cross-restart spend persistence, cross-process memory/fd red-team (#00).