Skip to content

feat(signerd): deckard-signerd — process-isolated signer daemon + policy gate (Closes #4) - #6

Merged
hellno merged 10 commits into
hellno/deckard-contractfrom
hellno/deckard-signerd
Jun 7, 2026
Merged

feat(signerd): deckard-signerd — process-isolated signer daemon + policy gate (Closes #4)#6
hellno merged 10 commits into
hellno/deckard-contractfrom
hellno/deckard-signerd

Conversation

@hellno

@hellno hellno commented Jun 5, 2026

Copy link
Copy Markdown
Owner

What

Implements #4deckard-signerd, the process-isolated signer daemon (the operator spine). A separate process owns the decrypted key, runs the real policy gate, signs + broadcasts Send transactions, and answers STOP. The GUI app (and the future deckard-mcp) become key-less clients over a same-uid Unix-domain socket.

Stacked on #3. origin/main predates the virtual-workspace migration + the deckard-contract crate (both live on hellno/deckard-contract / PR #3, which this extends), so this PR is based on that branch. Retarget to main once #3 lands.

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}).
  • New pure policy::evaluate(&Intent,&Policy) -> Decision — the one decision function. MockSigner now calls it (no duplicated logic; mock⇄daemon parity is unit-asserted).

deckard-signerd (AGPL-3.0-or-later; lib + bin)

  • Transport: tokio UnixListener, length-delimited CBOR frames (4-byte BE length + body, max 1 MiB); SO_PEERCRED/LOCAL_PEERCRED same-uid auth; socket 0600 in a 0700 dir; single-instance flock.
  • State machine: LockedUnlocked{vault} over deckard-core's keystore (reused, not rebuilt). Unlock decrypts + holds the key (passphrase moved into Zeroizing, raw frame scrubbed, never logged); Lock/RevokeAll zeroize → Locked + deny in-flight; re-unlock re-arms with a clean session.
  • Send path: process pre-checks (locked/chain_mismatch/unsupported_v1/erc20) → shared evaluate → store; EIP-1559 sign + broadcast via config RPC (DECKARD_RPC_URL/DECKARD_CHAIN_ID); deterministic keccak(intent) request id so an Allow is executable; Resolve approval loop; 120s TTL.
  • Sign-time guards: STOP denies a pre-approved request (TOCTOU); 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: the keystore yields an alloy-signer-local 2.0.5 signer; the daemon's provider is the alloy meta-crate (1.8.3). Only the version-stable B256 scalar 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/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.

Acceptance criteria

  1. cargo build --workspace ✅; daemon license = "AGPL-3.0-or-later"
  2. Socket 0600 in a 0700 dir + a unit test of the peer-cred check ✅
  3. Unlock correct→Unlocked{address} / wrong→BadPassphrase / missing→NoVault
  4. anvil-fork: within-cap→AllowBroadcast + real receipt; over-cap→NeedsApproval; off-allowlist→Deny; Shieldunsupported_v1; chain mismatch→chain_mismatch
  5. Approval loop: Resolve{true}AllowedBroadcast; Resolve{false} / TTL → Denied
  6. STOP zeroizes (Address→locked, execute→revoked) + re-unlock re-arms ✅
  7. TOCTOU: approve → revoke → execute → Denied{revoked}
  8. policy::evaluate parity: identical (Intent, Policy) → identical Decision (mock vs daemon) ✅
  9. App does no signing (unit + code review; UnlockedVault field removed) ✅
  10. Passphrase is 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 installs foundry-toolchain so 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:

  • Daily-cap bypass (execute didn't re-check spend) → execute now re-caps auto-allows.
  • Approvals never expired once Allowedexpire_stale now expires Allowed too.
  • Re-propose reset a denied/pending record → propose is now idempotent.
  • Hung RPC wedged the daemon + STOP → bounded broadcast timeout.
  • RPC-setting/daemon divergence is latent (no send UI yet) → documented as a T-UX fast-follow.

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).

hellno added 10 commits June 6, 2026 00:12
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
hellno merged commit c68c367 into hellno/deckard-contract Jun 7, 2026
1 of 2 checks passed
@hellno
hellno deleted the hellno/deckard-signerd branch June 7, 2026 20:17
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant