From 616c6ae3e7db1b5fc3bac8c8fe23b8a615b462a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 13:08:58 +0000 Subject: [PATCH] PRD-01: capability-gated Resolve (resolver authentication) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the same-uid-only Resolve gate with an unforgeable capability so the public proposer socket can no longer self-approve — closing THREAT-MODEL residual-risk #1 and unblocking any external proposer (#48/#50). Mechanism (resolves ADR 0001 PRD-01 open question → socketpair inheritance, app is the parent): the app mints an AF_UNIX socketpair before spawning the daemon, hands the child one end by fd inheritance (DECKARD_RESOLVE_FD), and keeps the other as a ControlChannel. The daemon honours Resolve only on that inherited end (Channel::Control); a Resolve on the public socket is refused with a typed resolve_not_authorized denial. STOP stays reachable on every channel. Each respawn re-mints the pair; the channel fails closed while the daemon is restarting. - deckard-contract: add resolve_not_authorized to the frozen deny vocabulary (no wire/frame change — the channel carries authority, so #31 doesn't apply). - deckard-signerd: Channel enum + gate in Daemon::handle; serve_control + adopt_control_fd (the one daemon-side unsafe, scoped + validated: rejects a non-stream fd, sets close-on-exec); ControlChannel + control_pair in supervise.rs with respawn re-handshake; blocking frame helpers. - deckard-app: route Resolve over the control channel, Execute over the public socket (execute only signs an already-Allowed record). - Tests: new resolver_auth.rs (public-socket Resolve rejected, control-channel accepted, STOP-on-public, red-team second-proposer-cannot-self-approve); migrate existing resolve sites to the control channel. - THREAT-MODEL residual #1 → Mitigated; ADR records the decision. CONTROL_TIMEOUT exceeds the daemon's broadcast lock-hold so normal back-pressure isn't mistaken for a dead channel. --- Cargo.lock | 1 + THREAT-MODEL.md | 46 ++-- crates/deckard-app/src/shell.rs | 8 +- crates/deckard-app/src/signer.rs | 100 +++++--- crates/deckard-contract/src/deny_reasons.rs | 6 + .../deckard-contract/tests/deny_vocabulary.rs | 5 +- crates/deckard-signerd/Cargo.toml | 6 +- crates/deckard-signerd/src/client.rs | 15 +- crates/deckard-signerd/src/daemon.rs | 33 ++- crates/deckard-signerd/src/frame.rs | 40 ++++ crates/deckard-signerd/src/lib.rs | 4 +- crates/deckard-signerd/src/main.rs | 25 +- crates/deckard-signerd/src/server.rs | 89 +++++++- crates/deckard-signerd/src/supervise.rs | 215 ++++++++++++++++-- crates/deckard-signerd/tests/anvil_e2e.rs | 11 +- crates/deckard-signerd/tests/common/mod.rs | 32 ++- crates/deckard-signerd/tests/daemon_e2e.rs | 60 +---- crates/deckard-signerd/tests/guardrail.rs | 15 +- crates/deckard-signerd/tests/resolver_auth.rs | 162 +++++++++++++ .../deckard-signerd/tests/swap_lifecycle.rs | 23 +- .../0001-dapp-connectivity-architecture.md | 13 ++ docs/build/31-agent-quickstart.md | 1 + 22 files changed, 723 insertions(+), 187 deletions(-) create mode 100644 crates/deckard-signerd/tests/resolver_auth.rs diff --git a/Cargo.lock b/Cargo.lock index ec29635..66dca6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8290,6 +8290,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", + "memoffset", ] [[package]] diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index a97f5d0..fde1a8e 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -14,24 +14,32 @@ that is not your user is not**. The daemon's Unix socket is `0600` inside a `070 directory and the daemon checks the peer's uid. There is no second boundary inside your user account: -- **`Resolve` is same-uid honor-system.** The daemon converts guarded writes to - `NeedsApproval`; the app's hold-to-confirm sends `Resolve { approved: true }` over - the socket, and the daemon cannot distinguish that from any other same-uid process - speaking the (documented) CBOR wire. The guardrail therefore constrains - **tool-confined agents** — a Claude Desktop instance whose only hands are the - mcp.v0.1 tools, which deliberately include **no `propose` and no `resolve`** — not - arbitrary code execution as your user. An agent with shell access is same-uid code: - it is on the trusted side of the boundary whether we like it or not, exactly as live - malware is for the keystore (see SECURITY.md's "stated honestly" section). +- **`Resolve` is gated by an unforgeable capability (PRD-01), not the honor system.** + The daemon converts guarded writes to `NeedsApproval`; approval + (`Resolve { approved: true }`) is honoured **only** on the private channel the daemon + inherits from the app that supervises it — a `socketpair` end passed at spawn + (`supervise.rs` mints it, `server.rs` serves it, `daemon.rs` gates on it). A `Resolve` + on the public proposer socket — any *other* same-uid process speaking the documented + CBOR wire — is refused with `resolve_not_authorized`. So a same-uid proposer (including + a fully compromised MCP sidecar) can propose and read, but it **cannot self-approve**; + the mcp.v0.1 tool surface still deliberately includes **no `propose` and no `resolve`** + (defense in depth). The honest residual: an agent with arbitrary **shell** access is + same-uid code that can reach the app's inherited fd anyway (ptrace, `/proc//fd`) — + it is on the trusted side of the boundary, exactly as live malware is for the keystore + (see SECURITY.md's "stated honestly" section). The capability closes the *cheap, + wire-level* self-approve that any same-uid process could previously do; it does not + fence off arbitrary code execution as your user. - **Request-ids are deterministic per intent** (no salt/namespace). Within the same-uid boundary this is fine — anyone who can compute your request-id can also just open their own connection. Salted/namespaced request-ids are on the roadmap for a future multi-principal daemon; they are deliberately not in the frozen v1 wire. -Authenticating the resolver (e.g. a socketpair or cookie handed only to the -supervised app process) is the known hardening step if a second principal ever shares -the socket. Until then: do not point this daemon at meaningful mainnet funds while -running unattended agents with shell access. That sentence is the threat model. +Authenticating the resolver — a `socketpair` capability handed only to the supervised +app — is now **implemented** (PRD-01): the public socket can no longer approve. The +standing caution is therefore narrower: do not point this daemon at meaningful mainnet +funds while running unattended agents with **shell** access, because same-uid code can +still reach the app's capability fd (ptrace / `/proc`). That sentence is the threat +model. ## Prompt injection, and what actually stops it @@ -178,10 +186,14 @@ connect. `spent_today` (`cap_exceeded`). Two within-cap proposals therefore can't both slip past the daily cap. -*Deferred:* the socket has no second principal today, so there is no resolver -authentication — `Resolve` is honored from any same-uid caller (the boundary section -above; red-team issue #2). Cross-restart spend persistence is also deferred -(`policy_store.rs`): `spent_today` is in-memory and resets on restart. +- **Resolver authentication** (`crates/deckard-signerd/src/{server,daemon,supervise}.rs`, + PRD-01): `Resolve` is honoured only on the private capability channel the daemon + inherits from the supervising app (a `socketpair` end passed at spawn); a `Resolve` on + this public socket is refused with `resolve_not_authorized`. The same-uid peer-cred + check stays as defense-in-depth + logging, not as the approval boundary. + +*Deferred:* cross-restart spend persistence (`policy_store.rs`): `spent_today` is +in-memory and resets on restart. ### 3. The policy store (the fence an attacker would want to widen) diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 7cb432c..38cf30a 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -1399,10 +1399,12 @@ impl Shell { self.shield_error = None; cx.notify(); let client = self.signer.client(); - // For a NeedsApproval proposal the completed hold IS the approval: resolve, then - // execute (signer::approve_and_execute_blocking). An Allow goes straight to execute. + let control = self.signer.control(); + // For a NeedsApproval proposal the completed hold IS the approval: resolve over the + // private capability channel, then execute (signer::approve_and_execute_blocking). An + // Allow goes straight to execute. let task = cx.background_spawn(async move { - signer::approve_and_execute_blocking(&client, request_id, needs_resolve) + signer::approve_and_execute_blocking(&client, &control, request_id, needs_resolve) }); cx.spawn(async move |this, cx| { let res = task.await; diff --git a/crates/deckard-app/src/signer.rs b/crates/deckard-app/src/signer.rs index 6231a40..e1f3149 100644 --- a/crates/deckard-app/src/signer.rs +++ b/crates/deckard-app/src/signer.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; use alloy_primitives::{Address, B256, U256}; use deckard_contract::{Decision, ExecuteResult, Intent, RequestId, UnlockOutcome}; -use deckard_signerd::{DaemonSupervisor, SignerClient}; +use deckard_signerd::{ControlChannel, DaemonSupervisor, SignerClient}; /// Result of the app's send path (propose, then execute on `Allow`). The path is implemented /// and unit-tested here; the GUI send screen that calls it is T-UX (out of scope), so no view @@ -56,6 +56,12 @@ impl AppSigner { pub fn client(&self) -> SignerClient { self.client.clone() } + + /// The private capability channel the daemon authenticates approvals on (PRD-01). The app + /// sends `Resolve` here after a completed hold-to-confirm; the public socket refuses it. + pub fn control(&self) -> ControlChannel { + self._supervisor.control() + } } /// Resolve the daemon socket path: the `DECKARD_SOCKET_PATH` override, else the per-uid @@ -111,17 +117,20 @@ pub fn send_blocking(client: &SignerClient, intent: &Intent) -> anyhow::Result anyhow::Result { if needs_resolve { - client.resolve_blocking(request_id, true)?; + control.resolve(request_id, true)?; } client.execute_blocking(request_id) } @@ -277,11 +286,13 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// The mainnet-guardrail regression guard: a `NeedsApproval` shield completes via the - /// resolve path — the app sends `Resolve{approved: true}` (the hold-to-confirm is the - /// human approval) and THEN `Execute`, over the socket, signing nothing in-process. + /// The mainnet-guardrail regression guard, post-PRD-01: a `NeedsApproval` shield completes + /// by sending `Resolve{approved: true}` over the **private capability channel** (the + /// hold-to-confirm is the human approval) and THEN `Execute` over the **public socket**, + /// signing nothing in-process. Proves the split: approval authority rides the control + /// channel, execution rides the public socket. #[test] - fn approve_and_execute_resolves_then_executes_over_the_socket() { + fn approve_resolves_over_control_then_executes_over_public_socket() { let dir = std::env::temp_dir().join(format!("deckard-appresolve-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); @@ -289,12 +300,12 @@ mod tests { let sock = dir.join("signerd.sock"); let seen: Arc>> = Arc::new(Mutex::new(Vec::new())); - let seen_srv = Arc::clone(&seen); - let sock_srv = sock.clone(); let (ready_tx, ready_rx) = mpsc::channel(); - // Recording server: two per-call connections (Resolve, then Execute). - let server = std::thread::spawn(move || { + // Public recording server: ONE connection (Execute only — Resolve never arrives here). + let seen_pub = Arc::clone(&seen); + let sock_srv = sock.clone(); + let public_server = std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() @@ -302,35 +313,48 @@ mod tests { rt.block_on(async move { let listener = tokio::net::UnixListener::bind(&sock_srv).unwrap(); ready_tx.send(()).unwrap(); - for _ in 0..2 { - let (mut stream, _) = listener.accept().await.unwrap(); - let buf = frame::read_frame(&mut stream).await.unwrap().unwrap(); - let req: SignerRequest = frame::decode(&buf).unwrap(); - let resp = match &req { - SignerRequest::Resolve { approved, .. } => { - assert!(*approved, "hold-to-confirm must approve, not deny"); - seen_srv.lock().unwrap().push("Resolve".into()); - SignerResponse::Ack - } - SignerRequest::Execute { .. } => { - seen_srv.lock().unwrap().push("Execute".into()); - SignerResponse::Execute(ExecuteResult::Broadcast { - tx_hash: B256::repeat_byte(0xCD), - }) - } - other => panic!("unexpected request on the wire: {other:?}"), - }; - let body = frame::encode(&resp).unwrap(); - frame::write_frame(&mut stream, &body).await.unwrap(); + let (mut stream, _) = listener.accept().await.unwrap(); + let buf = frame::read_frame(&mut stream).await.unwrap().unwrap(); + match frame::decode::(&buf).unwrap() { + SignerRequest::Execute { .. } => { + seen_pub.lock().unwrap().push("Execute".into()); + let resp = SignerResponse::Execute(ExecuteResult::Broadcast { + tx_hash: B256::repeat_byte(0xCD), + }); + let body = frame::encode(&resp).unwrap(); + frame::write_frame(&mut stream, &body).await.unwrap(); + } + other => panic!("public socket must only see Execute, got {other:?}"), } }); }); - ready_rx.recv().unwrap(); + // Control channel: a real socketpair (minted exactly as the supervisor does). The app + // keeps `control`; a thread plays the daemon end, expecting a single Resolve → Ack. + let (app_end, child_fd) = deckard_signerd::supervise::control_pair().unwrap(); + let control = deckard_signerd::ControlChannel::connected(app_end); + let seen_ctrl = Arc::clone(&seen); + let control_server = std::thread::spawn(move || { + let mut daemon_end = std::os::unix::net::UnixStream::from(child_fd); + let buf = frame::read_frame_blocking(&mut daemon_end) + .unwrap() + .unwrap(); + match frame::decode::(&buf).unwrap() { + SignerRequest::Resolve { approved, .. } => { + assert!(approved, "hold-to-confirm must approve, not deny"); + seen_ctrl.lock().unwrap().push("Resolve".into()); + let body = frame::encode(&SignerResponse::Ack).unwrap(); + frame::write_frame_blocking(&mut daemon_end, &body).unwrap(); + } + other => panic!("control channel saw a non-Resolve request: {other:?}"), + } + }); + let client = SignerClient::new(sock); let result = - approve_and_execute_blocking(&client, RequestId::repeat_byte(0x77), true).unwrap(); + approve_and_execute_blocking(&client, &control, RequestId::repeat_byte(0x77), true) + .unwrap(); assert_eq!( result, @@ -338,12 +362,14 @@ mod tests { tx_hash: B256::repeat_byte(0xCD) } ); + // Resolve (control) strictly precedes Execute (public): the Ack gates the execute. assert_eq!( *seen.lock().unwrap(), vec!["Resolve".to_string(), "Execute".to_string()] ); - server.join().unwrap(); + control_server.join().unwrap(); + public_server.join().unwrap(); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/deckard-contract/src/deny_reasons.rs b/crates/deckard-contract/src/deny_reasons.rs index 6d55008..a2c8d31 100644 --- a/crates/deckard-contract/src/deny_reasons.rs +++ b/crates/deckard-contract/src/deny_reasons.rs @@ -95,6 +95,12 @@ pub const DERIVATION_UNVERIFIED: &str = "derivation_unverified"; /// Built without the `shield` feature, so there is no Railgun derivation to grant. Only on the /// `#[cfg(not(feature = "shield"))]` path. pub const SHIELD_UNAVAILABLE: &str = "shield_unavailable"; +/// A `Resolve` arrived on the public proposer socket, which carries no approval authority. +/// Only the private capability channel the daemon inherits from the supervising app may +/// approve (PRD-01 / THREAT-MODEL residual #1) — so a same-uid proposer can no longer +/// self-approve. The pending record is left untouched (`Pending`); the public caller is +/// refused with this typed denial. +pub const RESOLVE_NOT_AUTHORIZED: &str = "resolve_not_authorized"; // ───────────────────────── Swap v1 (CoW) ───────────────────────── // Shaped-approve admission + order sign/cancel guards in the daemon, and the swap mock. diff --git a/crates/deckard-contract/tests/deny_vocabulary.rs b/crates/deckard-contract/tests/deny_vocabulary.rs index 38fb124..c9d3127 100644 --- a/crates/deckard-contract/tests/deny_vocabulary.rs +++ b/crates/deckard-contract/tests/deny_vocabulary.rs @@ -57,7 +57,7 @@ const PREFIX_BUILDERS: &[&str] = &[ "broadcast_failed", ]; -/// The complete frozen vocabulary: 30 static tags + 4 dynamic-prefix tags. Editing this list +/// The complete frozen vocabulary: 31 static tags + 4 dynamic-prefix tags. Editing this list /// is the deliberate gate — change it here, in `deny_reasons.rs`, AND (for a real, non-test /// tag) in `docs/build/31-agent-quickstart.md`. `swap_unsupported_in_mock` is test-surface /// only and is intentionally absent from the docs table. @@ -88,6 +88,7 @@ const FROZEN: &[&str] = &[ r::MALFORMED_REQUEST, r::DERIVATION_UNVERIFIED, r::SHIELD_UNAVAILABLE, + r::RESOLVE_NOT_AUTHORIZED, // swap v1 r::APPROVE_WITH_VALUE, r::APPROVE_WRONG_SPENDER, @@ -428,7 +429,7 @@ fn frozen_set_matches_module_exports() { fn frozen_set_is_exactly_documented() { assert_eq!( FROZEN.len(), - 34, + 35, "added/removed a Deny tag? update FROZEN, deny_reasons.rs, and the docs table" ); diff --git a/crates/deckard-signerd/Cargo.toml b/crates/deckard-signerd/Cargo.toml index b570516..7314500 100644 --- a/crates/deckard-signerd/Cargo.toml +++ b/crates/deckard-signerd/Cargo.toml @@ -62,8 +62,10 @@ serde_json = "1" alloy = { version = "1", features = ["provider-http", "network", "rpc-types", "signer-local", "eips"] } alloy-primitives = { workspace = true } -# Peer-cred uid (geteuid) + the single-instance flock. -nix = { version = "0.29", features = ["fs", "user"] } +# Peer-cred uid (geteuid) + the single-instance flock + `fcntl` FD_CLOEXEC hygiene (all under +# `fs`), plus `socket` for the resolver capability channel's `socketpair()` (PRD-01). Same +# crate as before — one new feature flag (`socket`), no new dependency. +nix = { version = "0.29", features = ["fs", "user", "socket"] } zeroize = "1" anyhow = "1" diff --git a/crates/deckard-signerd/src/client.rs b/crates/deckard-signerd/src/client.rs index c9ebe37..354eb53 100644 --- a/crates/deckard-signerd/src/client.rs +++ b/crates/deckard-signerd/src/client.rs @@ -140,17 +140,10 @@ impl SignerClient { } } - /// Blocking resolve: close a `NeedsApproval` loop by flipping its `Pending` record - /// to `Allowed` (`approved: true`) or `Denied` (`approved: false`). - pub fn resolve_blocking(&self, request_id: RequestId, approved: bool) -> anyhow::Result<()> { - match self.request_blocking(&SignerRequest::Resolve { - request_id, - approved, - })? { - SignerResponse::Ack => Ok(()), - other => Err(unexpected("Resolve", other)), - } - } + // NOTE: there is deliberately no `resolve` helper on this PUBLIC client. Since PRD-01 the + // daemon refuses `Resolve` on the public proposer socket (`resolve_not_authorized`); + // approval travels the private capability channel only — see + // [`deckard_signerd::ControlChannel::resolve`]. /// Blocking: fetch the read-only Railgun view grant (0zk address + viewing key) for /// shielded-balance sync. A locked daemon or a failed derivation gate comes back as a diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index fedf7c1..150faa0 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -112,6 +112,20 @@ struct PendingReq { /// rather than wedging the daemon (and STOP) forever behind the held state lock. const BROADCAST_TIMEOUT: Duration = Duration::from_secs(30); +/// Which channel a request arrived on — the load-bearing distinction for resolver +/// authentication (PRD-01). Same-uid peer-cred proves *who* connected, never *which role*; +/// the authority to approve is carried by possession of the private capability channel the +/// daemon inherits from the supervising app, NOT by the wire frame. So only [`Channel::Control`] +/// may `Resolve`; the public proposer socket can propose/read/execute/STOP but never approve. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Channel { + /// The public, same-uid proposer socket (`socket.rs`). Anything but `Resolve`. + Public, + /// The private capability channel handed only to the supervised app (`supervise.rs` → + /// inherited `socketpair` end). The sole channel that may `Resolve`. + Control, +} + /// The daemon's embedded Helios verified-read path, kept in a **separately-locked** cell so /// the multi-second-to-90s `launch_verified` bootstrap can run WITHOUT holding the daemon's /// own `Mutex`. The server clones this `Arc` and primes it (see [`HeliosCell::ensure`]) off @@ -223,14 +237,20 @@ impl Daemon { /// Dispatch one request to one response. `async` because `execute`/`balance` do network /// I/O and `unlock` runs Argon2 on the blocking pool. - pub async fn handle(&mut self, req: SignerRequest) -> SignerResponse { + /// + /// `channel` is the resolver-authentication gate (PRD-01): a `Resolve` is honoured only on + /// [`Channel::Control`]. Every other request behaves identically on both channels — STOP + /// (`Lock`/`RevokeAll`) deliberately stays reachable everywhere, since it only *reduces* + /// authority. + pub async fn handle(&mut self, req: SignerRequest, channel: Channel) -> SignerResponse { match req { SignerRequest::Unlock { passphrase } => { SignerResponse::Unlock(self.unlock(passphrase).await) } // Lock and RevokeAll are unified in v1: zeroize the key → Locked, deny everything // in flight, and — the STOP guarantee — best-effort cancel any already-signed swap - // order ON-CHAIN before the key is gone. Only a fresh Unlock re-arms. + // order ON-CHAIN before the key is gone. Only a fresh Unlock re-arms. Accepted on + // EITHER channel: the panic brake must never depend on the capability handshake. SignerRequest::Lock | SignerRequest::RevokeAll => { self.stop().await; SignerResponse::Ack @@ -239,6 +259,15 @@ impl Daemon { request_id, approved, } => { + // Resolver authentication: approval authority lives on the private capability + // channel ONLY. A `Resolve` on the public proposer socket — the textbook + // same-uid self-approval (THREAT-MODEL residual #1) — is refused with a typed, + // payload-free denial; the pending record is left untouched. + if channel != Channel::Control { + return SignerResponse::Decision(Decision::Deny { + reason: deny_reasons::RESOLVE_NOT_AUTHORIZED.into(), + }); + } self.resolve(request_id, approved); SignerResponse::Ack } diff --git a/crates/deckard-signerd/src/frame.rs b/crates/deckard-signerd/src/frame.rs index a58cfa0..e479581 100644 --- a/crates/deckard-signerd/src/frame.rs +++ b/crates/deckard-signerd/src/frame.rs @@ -57,6 +57,46 @@ where Ok(()) } +/// Blocking sibling of [`read_frame`] for the synchronous capability channel: the app's +/// supervisor talks to the daemon over a `socketpair` off the UI thread (see +/// [`crate::supervise::ControlChannel`]). Same framing, same 1 MiB cap, same clean-EOF vs +/// truncated-prefix distinction. +pub fn read_frame_blocking(r: &mut R) -> anyhow::Result>> { + let mut len_buf = [0u8; 4]; + let mut filled = 0; + while filled < len_buf.len() { + let n = r.read(&mut len_buf[filled..])?; + if n == 0 { + if filled == 0 { + return Ok(None); // clean EOF between frames + } + anyhow::bail!("truncated length prefix: {filled} of 4 bytes before EOF"); + } + filled += n; + } + let len = u32::from_be_bytes(len_buf) as usize; + anyhow::ensure!( + len <= MAX_FRAME, + "frame too large: {len} bytes > {MAX_FRAME}" + ); + let mut body = vec![0u8; len]; + r.read_exact(&mut body)?; + Ok(Some(body)) +} + +/// Blocking sibling of [`write_frame`] (length prefix + body, then flush). +pub fn write_frame_blocking(w: &mut W, body: &[u8]) -> anyhow::Result<()> { + anyhow::ensure!( + body.len() <= MAX_FRAME, + "frame too large: {} bytes", + body.len() + ); + w.write_all(&(body.len() as u32).to_be_bytes())?; + w.write_all(body)?; + w.flush()?; + Ok(()) +} + /// CBOR-encode a value into a frame body. pub fn encode(value: &T) -> anyhow::Result> { let mut buf = Vec::new(); diff --git a/crates/deckard-signerd/src/lib.rs b/crates/deckard-signerd/src/lib.rs index 8b623f1..56b5f0b 100644 --- a/crates/deckard-signerd/src/lib.rs +++ b/crates/deckard-signerd/src/lib.rs @@ -32,6 +32,6 @@ pub mod supervise; pub use client::SignerClient; pub use config::Config; -pub use daemon::Daemon; +pub use daemon::{Channel, Daemon}; pub use request_id::{request_id_for, request_id_for_order}; -pub use supervise::DaemonSupervisor; +pub use supervise::{ControlChannel, DaemonSupervisor}; diff --git a/crates/deckard-signerd/src/main.rs b/crates/deckard-signerd/src/main.rs index c184ef3..a4dc903 100644 --- a/crates/deckard-signerd/src/main.rs +++ b/crates/deckard-signerd/src/main.rs @@ -24,8 +24,31 @@ async fn main() -> anyhow::Result<()> { // Hold the single-instance lock for the whole process lifetime. let _lock = socket::single_instance(&cfg.socket_path)?; let listener = socket::bind(&cfg.socket_path)?; - eprintln!("signerd: listening (same-uid only)"); let daemon = Arc::new(Mutex::new(Daemon::new(cfg))); + + // Resolver authentication (PRD-01): if the supervising app passed us the inherited + // capability-channel fd, serve `Resolve` ONLY there. Without it the daemon refuses every + // `Resolve` (fail-closed) — the public socket can propose/read/execute/STOP but not approve. + match server::adopt_control_fd() { + Ok(Some(control)) => { + eprintln!("signerd: listening (public socket same-uid + private resolver channel)"); + let d = Arc::clone(&daemon); + tokio::spawn(async move { + if let Err(e) = server::serve_control(control, d).await { + eprintln!("signerd: resolver control channel closed: {e}"); + } + }); + } + Ok(None) => { + eprintln!("signerd: listening (same-uid only; no resolver channel — Resolve refused)"); + } + Err(e) => { + // A set-but-unusable fd is a launch misconfiguration: be loud, then run degraded + // (no approvals) rather than crash-loop. + eprintln!("signerd: resolver capability channel unavailable ({e}); Resolve refused"); + } + } + server::serve(listener, daemon).await } diff --git a/crates/deckard-signerd/src/server.rs b/crates/deckard-signerd/src/server.rs index 3599bd0..b22decc 100644 --- a/crates/deckard-signerd/src/server.rs +++ b/crates/deckard-signerd/src/server.rs @@ -1,6 +1,12 @@ //! The UDS server: accept connections, gate each on same-uid peer-cred, then serve a stream //! of length-delimited CBOR request/response frames. All requests funnel through the single //! shared [`Daemon`] behind a `tokio::sync::Mutex`, so they are serialized and can't race. +//! +//! Two channels reach the one daemon (PRD-01): +//! - the **public** [`serve`] accept-loop on the `0600` socket — propose/read/execute/STOP, +//! gated on same-uid peer-cred (defense-in-depth + logging), but never `Resolve`; +//! - the **private** [`serve_control`] single connection over the [`socketpair`] end the app +//! handed us by fd inheritance ([`adopt_control_fd`]) — the only channel that may `Resolve`. use std::sync::Arc; @@ -11,11 +17,17 @@ use zeroize::Zeroize; use deckard_contract::{deny_reasons, Decision, SignerRequest, SignerResponse}; use crate::auth; -use crate::daemon::Daemon; +use crate::daemon::{Channel, Daemon}; use crate::frame; -/// Accept loop. Rejects (and drops) any connection whose peer uid differs from ours, then -/// spawns a per-connection task. Runs until the listener errors fatally. +/// Env var naming the inherited capability-channel fd (set by [`crate::supervise`] on the +/// daemon child). Its presence is what arms `Resolve` on the control channel; without it the +/// daemon serves only the public socket and `Resolve` is refused everywhere (fail-closed). +pub const RESOLVE_FD_ENV: &str = "DECKARD_RESOLVE_FD"; + +/// Accept loop for the **public** proposer socket. Rejects (and drops) any connection whose +/// peer uid differs from ours, then spawns a per-connection task tagged [`Channel::Public`]. +/// Runs until the listener errors fatally. pub async fn serve(listener: UnixListener, daemon: Arc>) -> anyhow::Result<()> { let our = auth::our_uid(); loop { @@ -41,17 +53,78 @@ pub async fn serve(listener: UnixListener, daemon: Arc>) -> anyhow let daemon = Arc::clone(&daemon); tokio::spawn(async move { - if let Err(e) = handle_conn(stream, daemon).await { + if let Err(e) = handle_conn(stream, daemon, Channel::Public).await { eprintln!("signerd: connection closed: {e}"); } }); } } +/// Serve the **private** capability channel: a single, already-connected `socketpair` end +/// inherited from the supervising app. There is no `accept`/peer-cred here — the channel is +/// trusted by construction (a same-process-tree fd the app never shares), which is precisely +/// the unforgeable role proof same-uid peer-cred cannot give. Requests are tagged +/// [`Channel::Control`], so this is the only place a `Resolve` is honoured. +pub async fn serve_control(stream: UnixStream, daemon: Arc>) -> anyhow::Result<()> { + handle_conn(stream, daemon, Channel::Control).await +} + +/// Adopt the inherited capability-channel fd named by [`RESOLVE_FD_ENV`], if present, into a +/// tokio [`UnixStream`]. Returns `Ok(None)` when the env var is absent (the daemon then has no +/// control channel and refuses every `Resolve`). A malformed/unusable value is surfaced as an +/// error so a misconfiguration is loud, not silent. +pub fn adopt_control_fd() -> anyhow::Result> { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; + use std::os::unix::net::UnixStream as StdUnixStream; + + let Some(val) = std::env::var_os(RESOLVE_FD_ENV) else { + return Ok(None); + }; + let fd: RawFd = val + .to_str() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| anyhow::anyhow!("{RESOLVE_FD_ENV} is not a valid fd number"))?; + + // SAFETY: the supervising parent (`crate::supervise`) created an AF_UNIX `socketpair`, + // left this end without close-on-exec, and named it in `{RESOLVE_FD_ENV}` for us to + // inherit across exec. We are the sole owner of this fd in this process — the parent + // closed its copy of the child end right after spawn — so adopting it into an `OwnedFd` + // (whose `Drop` then closes it exactly once) is sound. reason: there is no safe std API + // to reclaim an inherited fd by number; this is the boundary the issue scopes the one + // `unsafe` to (daemon-side), keeping `deckard-core`'s `#![forbid(unsafe_code)]` intact. + #[allow(unsafe_code)] + let owned = unsafe { OwnedFd::from_raw_fd(fd) }; + + // Validate before trusting: `{RESOLVE_FD_ENV}` is just an integer in the environment, so a + // stale/crafted value could name an fd we already own (the public listener, stdout, …). + // Confirm it really is a stream socket — otherwise refuse loudly rather than (a) treating an + // arbitrary inherited fd as the approval-authorised channel or (b) double-closing a live fd + // on `OwnedFd`'s `Drop`. Then set close-on-exec so the capability never leaks further. + let sock_type = nix::sys::socket::getsockopt(&owned, nix::sys::socket::sockopt::SockType) + .map_err(|e| anyhow::anyhow!("{RESOLVE_FD_ENV} is not a socket: {e}"))?; + anyhow::ensure!( + sock_type == nix::sys::socket::SockType::Stream, + "{RESOLVE_FD_ENV} fd is not a stream socket" + ); + nix::fcntl::fcntl( + owned.as_raw_fd(), + nix::fcntl::FcntlArg::F_SETFD(nix::fcntl::FdFlag::FD_CLOEXEC), + ) + .map_err(|e| anyhow::anyhow!("set close-on-exec on control fd: {e}"))?; + + let std_stream = StdUnixStream::from(owned); + std_stream.set_nonblocking(true)?; + Ok(Some(UnixStream::from_std(std_stream)?)) +} + /// Serve one connection: read a frame, decode it, zeroize the raw bytes (they may hold an -/// `Unlock` passphrase), dispatch, write the response. A malformed/oversize frame gets one -/// error response and the connection is closed. -async fn handle_conn(mut stream: UnixStream, daemon: Arc>) -> anyhow::Result<()> { +/// `Unlock` passphrase), dispatch on `channel`, write the response. A malformed/oversize frame +/// gets one error response and the connection is closed. +async fn handle_conn( + mut stream: UnixStream, + daemon: Arc>, + channel: Channel, +) -> anyhow::Result<()> { loop { let mut buf = match frame::read_frame(&mut stream).await { Ok(Some(buf)) => buf, @@ -93,7 +166,7 @@ async fn handle_conn(mut stream: UnixStream, daemon: Arc>) -> anyh // Dispatch behind the shared lock (serializes all requests). Note: we deliberately // never log the request contents — an Unlock passphrase must never reach a log line. - let resp = daemon.lock().await.handle(req).await; + let resp = daemon.lock().await.handle(req, channel).await; let body = frame::encode(&resp)?; frame::write_frame(&mut stream, &body).await?; diff --git a/crates/deckard-signerd/src/supervise.rs b/crates/deckard-signerd/src/supervise.rs index 3b8c472..9aaaca7 100644 --- a/crates/deckard-signerd/src/supervise.rs +++ b/crates/deckard-signerd/src/supervise.rs @@ -5,13 +5,29 @@ //! `DECKARD_SIGNERD_BIN`, else next to the app binary, else `deckard-signerd` on `PATH`. The //! socket path is passed explicitly so the app's [`SignerClient`](crate::SignerClient) and //! the daemon agree. The child inherits stdout/stderr → the app's log. +//! +//! ## Resolver authentication (PRD-01) +//! +//! Because the app is the *parent*, it mints a private `AF_UNIX` [`socketpair`] before each +//! spawn, hands the daemon child one end by fd inheritance (its number in +//! [`crate::server::RESOLVE_FD_ENV`]), and keeps the other end as a [`ControlChannel`]. A +//! `Resolve` (approval) is honoured by the daemon ONLY on that channel — the public proposer +//! socket refuses it — so a same-uid process can no longer self-approve (THREAT-MODEL +//! residual #1). Each respawn re-mints the pair; while the daemon is restarting the channel is +//! disconnected and `Resolve` fails *closed*, never silently approving. +use std::os::fd::{AsRawFd, OwnedFd}; +use std::os::unix::net::UnixStream as StdUnixStream; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; +use deckard_contract::{RequestId, SignerRequest, SignerResponse}; + +use crate::frame; + /// Resolve the `deckard-signerd` binary: explicit override, then a sibling of the running /// app binary, then the bare name (PATH lookup). pub fn resolve_binary() -> PathBuf { @@ -29,21 +45,153 @@ pub fn resolve_binary() -> PathBuf { PathBuf::from("deckard-signerd") } +/// How long a blocking `Resolve` round-trip waits on the control channel before giving up, so a +/// genuinely wedged daemon can never hang the caller (the app's background thread) forever. +/// +/// It MUST comfortably exceed the daemon's own worst-case lock hold: the daemon serializes every +/// request behind one mutex and holds it across a broadcast bounded at `BROADCAST_TIMEOUT` (30s, +/// `daemon.rs`), so a `Resolve` can legitimately queue ~30s behind an in-flight `execute`. A +/// shorter budget would misread that normal back-pressure as a dead socket, tear the channel +/// down, and (the control channel is a single non-re-accepting connection) brick approvals until +/// the next daemon restart. A truly dead daemon still fails fast — its end closes, so the read +/// returns EOF/`ECONNRESET` long before this ceiling. The wait runs off the UI thread. +const CONTROL_TIMEOUT: Duration = Duration::from_secs(35); + +/// The app's end of the private capability channel (PRD-01). A `Resolve` travels here and ONLY +/// here; the daemon's public socket refuses approvals. Re-minted on every daemon (re)spawn — +/// while the daemon is restarting the slot is empty and [`resolve`](Self::resolve) fails closed +/// (it never silently approves). Cheap to clone; all clones share one slot. +#[derive(Clone)] +pub struct ControlChannel { + inner: Arc>>, +} + +impl ControlChannel { + /// An unconnected channel (no live daemon end yet). The supervisor publishes a fresh end on + /// each spawn; tests that spawn the daemon binary themselves use [`connected`](Self::connected). + pub fn disconnected() -> Self { + Self { + inner: Arc::new(Mutex::new(None)), + } + } + + /// Wrap an already-connected app end (the integration-test harness spawns the daemon and + /// passes the inherited fd just like the app does). + pub fn connected(stream: StdUnixStream) -> Self { + Self { + inner: Arc::new(Mutex::new(Some(stream))), + } + } + + /// Install a freshly-minted app end (a daemon (re)spawn just happened). + fn publish(&self, stream: StdUnixStream) { + if let Ok(mut slot) = self.inner.lock() { + *slot = Some(stream); + } + } + + /// Drop the app end (the daemon exited) so the next `resolve` fails closed until the + /// supervisor re-handshakes with the restarted daemon. + fn disconnect(&self) { + if let Ok(mut slot) = self.inner.lock() { + *slot = None; + } + } + + /// Send `Resolve { request_id, approved }` over the capability channel and await the + /// daemon's `Ack`. Fails closed when the channel isn't currently connected (daemon + /// mid-respawn). + /// + /// On any transport error the end is dropped. This is deliberate, not just hygiene: the + /// `Ack` carries no request id, so reusing a stream after a timed-out/partial read could + /// pair a later call with a stale, delayed `Ack` (a silent off-by-one). Dropping forces a + /// fresh handshake instead. A genuinely dead daemon is then re-handshaked by the supervisor + /// when it observes the child exit (`monitor_loop` → `disconnect`/`publish`); the generous + /// [`CONTROL_TIMEOUT`] ensures normal back-pressure (a `Resolve` queued behind an in-flight + /// broadcast) is never mistaken for a dead socket and torn down. + pub fn resolve(&self, request_id: RequestId, approved: bool) -> anyhow::Result<()> { + let mut guard = self + .inner + .lock() + .map_err(|_| anyhow::anyhow!("control channel poisoned"))?; + let stream = guard.as_mut().ok_or_else(|| { + anyhow::anyhow!("approval channel not connected (daemon restarting) — hold again") + })?; + let result = round_trip(stream, request_id, approved); + if result.is_err() { + *guard = None; // drop the broken end → re-handshake on the next attempt + } + result + } +} + +/// One blocking `Resolve` → `Ack` round-trip over the capability channel. +fn round_trip( + stream: &mut StdUnixStream, + request_id: RequestId, + approved: bool, +) -> anyhow::Result<()> { + let body = frame::encode(&SignerRequest::Resolve { + request_id, + approved, + })?; + frame::write_frame_blocking(stream, &body)?; + let resp = frame::read_frame_blocking(stream)? + .ok_or_else(|| anyhow::anyhow!("control channel closed before responding"))?; + match frame::decode::(&resp)? { + SignerResponse::Ack => Ok(()), + other => Err(anyhow::anyhow!("unexpected control response: {other:?}")), + } +} + +/// Create the private capability `socketpair`. Returns `(app_end, child_fd)`: +/// - `app_end` — the app keeps this; `Resolve` frames travel here. It is set close-on-exec so +/// a later daemon respawn never inherits a stale app end, and carries a read timeout so a +/// wedged daemon can't hang the caller. +/// - `child_fd` — handed to the spawned daemon by inheritance (its number goes in +/// [`crate::server::RESOLVE_FD_ENV`]); deliberately left WITHOUT close-on-exec so it survives +/// `exec`. The caller drops it immediately after spawn — the child holds its own copy. +pub fn control_pair() -> anyhow::Result<(StdUnixStream, OwnedFd)> { + use nix::sys::socket::{socketpair, AddressFamily, SockFlag, SockType}; + + let (app_owned, child_owned) = socketpair( + AddressFamily::Unix, + SockType::Stream, + None, + SockFlag::empty(), + )?; + // App end: close-on-exec (never inherited by a future child) + a bounded read timeout. + set_cloexec(&app_owned)?; + let app_end = StdUnixStream::from(app_owned); + app_end.set_read_timeout(Some(CONTROL_TIMEOUT))?; + Ok((app_end, child_owned)) +} + +/// Set `FD_CLOEXEC` on a borrowed fd (safe nix wrapper around `fcntl(F_SETFD)`). +fn set_cloexec(fd: &F) -> anyhow::Result<()> { + use nix::fcntl::{fcntl, FcntlArg, FdFlag}; + fcntl(fd.as_raw_fd(), FcntlArg::F_SETFD(FdFlag::FD_CLOEXEC))?; + Ok(()) +} + /// A running, self-restarting daemon child. Dropping it stops the supervisor and kills the /// child. pub struct DaemonSupervisor { shutdown: Arc, child: Arc>>, socket_path: PathBuf, + control: ControlChannel, } impl DaemonSupervisor { /// Spawn the daemon bound to `socket_path` (broadcasting via `rpc_url` on `chain_id`) and a /// monitor thread that respawns it on crash. The child inherits the current environment - /// plus `DECKARD_SOCKET_PATH`/`DECKARD_RPC_URL`/`DECKARD_CHAIN_ID`. + /// plus `DECKARD_SOCKET_PATH`/`DECKARD_RPC_URL`/`DECKARD_CHAIN_ID` and the inherited + /// capability-channel fd (`DECKARD_RESOLVE_FD`). pub fn spawn(socket_path: PathBuf, rpc_url: String, chain_id: u64) -> Self { let shutdown = Arc::new(AtomicBool::new(false)); let child: Arc>> = Arc::new(Mutex::new(None)); + let control = ControlChannel::disconnected(); let bin = resolve_binary(); let env = ChildEnv { socket_path: socket_path.clone(), @@ -55,11 +203,12 @@ impl DaemonSupervisor { shutdown: Arc::clone(&shutdown), child: Arc::clone(&child), socket_path, + control: control.clone(), }; std::thread::Builder::new() .name("deckard-signerd-sup".into()) - .spawn(move || monitor_loop(bin, env, shutdown, child)) + .spawn(move || monitor_loop(bin, env, shutdown, child, control)) .ok(); sup @@ -70,10 +219,17 @@ impl DaemonSupervisor { &self.socket_path } - /// A client wired to this daemon's socket. + /// A client wired to this daemon's socket (the public proposer channel: propose/read/ + /// execute/STOP). pub fn client(&self) -> crate::SignerClient { crate::SignerClient::new(self.socket_path.clone()) } + + /// The private capability channel to the supervised daemon — the app sends `Resolve` here + /// (the public socket refuses it). Cheap to clone. + pub fn control(&self) -> ControlChannel { + self.control.clone() + } } impl Drop for DaemonSupervisor { @@ -95,24 +251,54 @@ struct ChildEnv { chain_id: u64, } -/// Spawn → poll-until-exit → backoff → respawn, until shutdown is signalled. +/// Spawn → poll-until-exit → backoff → respawn, until shutdown is signalled. Each spawn mints a +/// FRESH capability channel (an inherited fd serves exactly one daemon instance), publishes the +/// app end while the daemon is alive, and disconnects it when the daemon exits — so a restarted +/// daemon re-handshakes and there is never a window where `Resolve` is ungated. fn monitor_loop( bin: PathBuf, env: ChildEnv, shutdown: Arc, child_slot: Arc>>, + control: ControlChannel, ) { let mut backoff = Duration::from_millis(200); while !shutdown.load(Ordering::SeqCst) { - match Command::new(&bin) - .env("DECKARD_SOCKET_PATH", &env.socket_path) - .env("DECKARD_RPC_URL", &env.rpc_url) - .env("DECKARD_CHAIN_ID", env.chain_id.to_string()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()) - .spawn() - { - Ok(child) => { + // Mint this instance's capability channel before spawning so the child can inherit it. + let spawned = match control_pair() { + Ok((app_end, child_fd)) => { + let result = Command::new(&bin) + .env("DECKARD_SOCKET_PATH", &env.socket_path) + .env("DECKARD_RPC_URL", &env.rpc_url) + .env("DECKARD_CHAIN_ID", env.chain_id.to_string()) + .env( + crate::server::RESOLVE_FD_ENV, + child_fd.as_raw_fd().to_string(), + ) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn(); + // The child inherited its own copy of `child_fd`; drop ours unconditionally so + // the daemon end isn't held open by the parent. + drop(child_fd); + result.map(|child| (child, app_end)) + } + Err(e) => { + eprintln!("deckard: failed to create resolver capability channel: {e}"); + // Skip this spawn attempt; back off and retry. + if shutdown.load(Ordering::SeqCst) { + return; + } + std::thread::sleep(backoff); + backoff = (backoff * 2).min(Duration::from_secs(5)); + continue; + } + }; + + match spawned { + Ok((child, app_end)) => { + // The daemon is live: publish its control end so `Resolve` works. + control.publish(app_end); if let Ok(mut slot) = child_slot.lock() { *slot = Some(child); } @@ -144,6 +330,9 @@ fn monitor_loop( std::thread::sleep(Duration::from_millis(200)); backoff = Duration::from_millis(200); // healthy run resets the backoff } + // The daemon is gone: tear down its capability channel so a stale end is never + // used against the next instance (the next iteration mints a fresh one). + control.disconnect(); } Err(e) => { eprintln!("deckard: failed to spawn signerd ({}): {e}", bin.display()); diff --git a/crates/deckard-signerd/tests/anvil_e2e.rs b/crates/deckard-signerd/tests/anvil_e2e.rs index 0a7371c..d5d2665 100644 --- a/crates/deckard-signerd/tests/anvil_e2e.rs +++ b/crates/deckard-signerd/tests/anvil_e2e.rs @@ -97,16 +97,7 @@ async fn over_cap_approve_then_execute_broadcasts() { }; // Approve (the native card / a test), then execute → broadcast. - assert_eq!( - client - .request(&SignerRequest::Resolve { - request_id: id, - approved: true - }) - .await - .unwrap(), - SignerResponse::Ack - ); + d.resolve(id, true); assert_eq!( client .request(&SignerRequest::Status { request_id: id }) diff --git a/crates/deckard-signerd/tests/common/mod.rs b/crates/deckard-signerd/tests/common/mod.rs index 5c6eb3e..ec7ee16 100644 --- a/crates/deckard-signerd/tests/common/mod.rs +++ b/crates/deckard-signerd/tests/common/mod.rs @@ -70,10 +70,24 @@ pub fn seal_account0(dir: &Path) -> (Address, Address) { (wallet, recipient) } -/// A spawned daemon process; killed on drop. +/// A spawned daemon process; killed on drop. Carries the private capability channel the test +/// harness mints exactly as the app's supervisor does (PRD-01), so a test can approve via +/// [`DaemonProc::resolve`] — a `Resolve` on the public socket is now refused. pub struct DaemonProc { child: Child, pub socket_path: PathBuf, + control: deckard_signerd::ControlChannel, +} + +impl DaemonProc { + /// Approve/deny a pending request over the authenticated control channel (the daemon + /// refuses `Resolve` on the public socket). Panics on a transport error — a test that + /// can't reach the resolver channel should fail loudly. + pub fn resolve(&self, request_id: deckard_contract::RequestId, approved: bool) { + self.control + .resolve(request_id, approved) + .expect("resolve over control channel"); + } } impl Drop for DaemonProc { @@ -84,13 +98,17 @@ impl Drop for DaemonProc { } /// Spawn the real `deckard-signerd` binary against `dir` (config + socket) and `rpc_url`, -/// waiting until it binds the socket. +/// waiting until it binds the socket. Attaches the resolver capability channel by fd +/// inheritance, mirroring `deckard_signerd::supervise` — the daemon serves `Resolve` only on +/// that inherited end. pub fn spawn_daemon( dir: &Path, rpc_url: &str, chain_id: u64, extra_env: &[(&str, &str)], ) -> DaemonProc { + use std::os::fd::AsRawFd; + let socket = dir.join("signerd.sock"); let mut cmd = Command::new(env!("CARGO_BIN_EXE_deckard-signerd")); cmd.env("DECKARD_CONFIG_DIR", dir) @@ -100,7 +118,16 @@ pub fn spawn_daemon( for (k, v) in extra_env { cmd.env(k, v); } + + // Mint + pass the capability channel exactly as the app supervisor does: the child + // inherits `child_fd` (named in DECKARD_RESOLVE_FD); the harness keeps the app end. + let (app_end, child_fd) = deckard_signerd::supervise::control_pair().expect("control pair"); + cmd.env("DECKARD_RESOLVE_FD", child_fd.as_raw_fd().to_string()); + let child = cmd.spawn().expect("spawn deckard-signerd binary"); + drop(child_fd); // the child inherited its own copy + let control = deckard_signerd::ControlChannel::connected(app_end); + assert!( wait_for(|| socket.exists(), Duration::from_secs(5)), "daemon never bound its socket" @@ -108,6 +135,7 @@ pub fn spawn_daemon( DaemonProc { child, socket_path: socket, + control, } } diff --git a/crates/deckard-signerd/tests/daemon_e2e.rs b/crates/deckard-signerd/tests/daemon_e2e.rs index 367fe96..79e3ec3 100644 --- a/crates/deckard-signerd/tests/daemon_e2e.rs +++ b/crates/deckard-signerd/tests/daemon_e2e.rs @@ -224,14 +224,7 @@ async fn resolve_false_and_ttl_deny_execute() { // resolve(false) → execute Denied{user_denied}. let over = send(to, PER_TX_CAP + 1); let id = needs_approval_id(&client, &over).await; - ack( - &client, - SignerRequest::Resolve { - request_id: id, - approved: false, - }, - ) - .await; + d.resolve(id, false); assert_eq!( client.execute(id).await.unwrap(), ExecuteResult::Denied { @@ -300,14 +293,7 @@ async fn toctou_resolve_then_revoke_then_execute_denied() { let over = send(to, PER_TX_CAP + 1); let id = needs_approval_id(&client, &over).await; - ack( - &client, - SignerRequest::Resolve { - request_id: id, - approved: true, - }, - ) - .await; + d.resolve(id, true); assert_eq!(status(&client, id).await, ApprovalStatus::Allowed); // STOP after approval but before execute. ack(&client, SignerRequest::RevokeAll).await; @@ -352,14 +338,7 @@ async fn allowed_request_expires() { let over = send(to, PER_TX_CAP + 1); let id = needs_approval_id(&client, &over).await; - ack( - &client, - SignerRequest::Resolve { - request_id: id, - approved: true, - }, - ) - .await; + d.resolve(id, true); assert_eq!(status(&client, id).await, ApprovalStatus::Allowed); tokio::time::sleep(std::time::Duration::from_millis(1_500)).await; @@ -390,14 +369,7 @@ async fn re_propose_is_idempotent() { // (b) a user Deny is sticky — re-propose does NOT re-raise the card. let denied = send(to, PER_TX_CAP + 1); let id = needs_approval_id(&client, &denied).await; - ack( - &client, - SignerRequest::Resolve { - request_id: id, - approved: false, - }, - ) - .await; + d.resolve(id, false); assert_eq!( client.propose(&denied).await.unwrap(), Decision::Deny { @@ -408,14 +380,7 @@ async fn re_propose_is_idempotent() { // (c) an approval is not downgraded back to Pending by a re-propose. let approved = send(to, PER_TX_CAP + 2); let id2 = needs_approval_id(&client, &approved).await; - ack( - &client, - SignerRequest::Resolve { - request_id: id2, - approved: true, - }, - ) - .await; + d.resolve(id2, true); assert_eq!(client.propose(&approved).await.unwrap(), Decision::Allow); } @@ -441,18 +406,13 @@ async fn two_clients_interleave_app_and_mcp_sessions() { assert_eq!(mcp.propose(&within).await.unwrap(), Decision::Allow); let within_id = SignerClient::request_id_for_intent(&within); - // 2. An over-cap MCP request raises a card; the APP resolves it (it is the designated - // human-facing resolver) and the MCP client observes the approval. + // 2. An over-cap MCP request raises a card; the APP resolves it over its private capability + // channel (it is the designated human-facing resolver — the MCP client, like any public + // caller, is refused `Resolve`; see resolver_auth.rs) and the MCP client observes the + // approval. let over = send(to, PER_TX_CAP + 1); let over_id = needs_approval_id(&mcp, &over).await; - ack( - &app, - SignerRequest::Resolve { - request_id: over_id, - approved: true, - }, - ) - .await; + d.resolve(over_id, true); assert_eq!(status(&mcp, over_id).await, ApprovalStatus::Allowed); // 3. MCP STOP (revoke_all) — the panic brake cuts BOTH clients: the app's next call diff --git a/crates/deckard-signerd/tests/guardrail.rs b/crates/deckard-signerd/tests/guardrail.rs index e9cff0b..5b697bc 100644 --- a/crates/deckard-signerd/tests/guardrail.rs +++ b/crates/deckard-signerd/tests/guardrail.rs @@ -174,18 +174,9 @@ async fn guardrail_needs_approval_resolves_then_executes() { other => panic!("expected not_approved, got {other:?}"), } - // Human approval (the app's hold-to-confirm) → Allowed → execute reaches broadcast. - match client - .request(&SignerRequest::Resolve { - request_id: id, - approved: true, - }) - .await - .unwrap() - { - SignerResponse::Ack => {} - other => panic!("expected Ack, got {other:?}"), - } + // Human approval (the app's hold-to-confirm) over the authenticated control channel → + // Allowed → execute reaches broadcast. (A `Resolve` on the public socket is now refused.) + d.resolve(id, true); match client .request(&SignerRequest::Status { request_id: id }) .await diff --git a/crates/deckard-signerd/tests/resolver_auth.rs b/crates/deckard-signerd/tests/resolver_auth.rs new file mode 100644 index 0000000..7b5e4f8 --- /dev/null +++ b/crates/deckard-signerd/tests/resolver_auth.rs @@ -0,0 +1,162 @@ +//! PRD-01 — resolver authentication. The daemon honours `Resolve` (approval) ONLY on the +//! private capability channel the supervising app inherits (`supervise.rs` → `socketpair`); +//! a `Resolve` on the public same-uid proposer socket is refused with a typed denial. This +//! closes `THREAT-MODEL.md` residual-risk #1 (same-uid self-approval) and is the prerequisite +//! for any external proposer. +//! +//! These run under plain `cargo test` (none `#[ignore]`); no chain is needed (nothing is +//! broadcast — every assertion is about the approval gate, not signing). + +mod common; + +use alloy_primitives::{Address, Bytes, U256}; +use deckard_contract::{ + ApprovalStatus, Decision, Intent, IntentKind, SignerRequest, SignerResponse, +}; +use deckard_signerd::SignerClient; + +use common::*; + +const CHAIN: u64 = 31337; +/// Nothing is broadcast here, so the RPC is never contacted. +const DUMMY_RPC: &str = "http://127.0.0.1:1"; +const PER_TX_CAP: u64 = 50_000_000_000_000_000; // 0.05 ETH (the default policy cap) + +fn send(to: Address, value: u64) -> Intent { + Intent { + chain_id: CHAIN, + to, + token: None, + value: U256::from(value), + calldata: Bytes::new(), + kind: IntentKind::Send, + } +} + +/// Propose an over-cap Send → its `NeedsApproval` request id (a `Pending` card to approve). +async fn pending_id(client: &SignerClient, to: Address) -> deckard_contract::RequestId { + match client.propose(&send(to, PER_TX_CAP + 1)).await.unwrap() { + Decision::NeedsApproval { request_id } => request_id, + other => panic!("expected NeedsApproval, got {other:?}"), + } +} + +async fn status(client: &SignerClient, id: deckard_contract::RequestId) -> ApprovalStatus { + match client + .request(&SignerRequest::Status { request_id: id }) + .await + .unwrap() + { + SignerResponse::Status(s) => s, + other => panic!("expected Status, got {other:?}"), + } +} + +#[tokio::test] +async fn resolve_rejected_on_public_socket() { + // A `Resolve` on the public proposer socket is refused with a typed, payload-free denial, + // and the pending record is left untouched (still `Pending`, still approvable by the app). + let dir = TempDir::new("resolver-public-reject"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let id = pending_id(&client, to).await; + + // Public-socket Resolve → Deny{resolve_not_authorized}. + match client + .request(&SignerRequest::Resolve { + request_id: id, + approved: true, + }) + .await + .unwrap() + { + SignerResponse::Decision(Decision::Deny { reason }) => { + assert_eq!(reason, "resolve_not_authorized") + } + other => panic!("public Resolve must be denied, got {other:?}"), + } + + // The record was NOT approved — it is still Pending. + assert_eq!(status(&client, id).await, ApprovalStatus::Pending); +} + +#[tokio::test] +async fn resolve_accepted_on_control_channel() { + // Over the inherited capability channel, the same `Resolve` flips Pending → Allowed. + let dir = TempDir::new("resolver-control-accept"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let id = pending_id(&client, to).await; + d.resolve(id, true); // the authenticated channel + assert_eq!(status(&client, id).await, ApprovalStatus::Allowed); +} + +#[tokio::test] +async fn stop_still_works_on_public_socket() { + // STOP only REDUCES authority, so it stays reachable on the public socket (it must never + // depend on the capability handshake). + let dir = TempDir::new("resolver-stop-public"); + let (_wallet, _to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + assert_eq!( + client.request(&SignerRequest::RevokeAll).await.unwrap(), + SignerResponse::Ack + ); + // The key is gone: Address reports locked Deny-style. + match client.request(&SignerRequest::Address).await.unwrap() { + SignerResponse::Decision(Decision::Deny { reason }) => assert_eq!(reason, "locked"), + other => panic!("expected locked Deny after public STOP, got {other:?}"), + } +} + +#[tokio::test] +async fn second_proposer_cannot_self_approve() { + // The red-team scenario (issue #19 / residual #1): a SECOND same-uid proposer opens its own + // connection, sees the NeedsApproval, and rubber-stamps it — exactly the ~20-line bypass. + // Post-PRD-01 it is refused: a proposer can propose, but it cannot approve. Only the app's + // inherited control channel can. + let dir = TempDir::new("resolver-red-team"); + let (_wallet, to) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + + // The honest proposer (e.g. the MCP sidecar) raises a card. + let proposer = SignerClient::new(d.socket_path.clone()); + proposer.unlock(PASS).await.unwrap(); + let id = pending_id(&proposer, to).await; + + // The attacker: a second same-uid client speaking the documented wire. It can recompute the + // id (deterministic) and tries to self-approve over its own public connection. + let attacker = SignerClient::new(d.socket_path.clone()); + match attacker + .request(&SignerRequest::Resolve { + request_id: id, + approved: true, + }) + .await + .unwrap() + { + SignerResponse::Decision(Decision::Deny { reason }) => { + assert_eq!(reason, "resolve_not_authorized") + } + other => panic!("the attacker's self-approve must be denied, got {other:?}"), + } + // Still Pending — the bypass failed, and execute is refused at the approval gate. + assert_eq!(status(&proposer, id).await, ApprovalStatus::Pending); + match proposer.execute(id).await.unwrap() { + deckard_contract::ExecuteResult::Denied { reason } => assert_eq!(reason, "not_approved"), + other => panic!("an unapproved request must not execute, got {other:?}"), + } + + // The legitimate resolver (the app's capability channel) CAN approve. + d.resolve(id, true); + assert_eq!(status(&proposer, id).await, ApprovalStatus::Allowed); +} diff --git a/crates/deckard-signerd/tests/swap_lifecycle.rs b/crates/deckard-signerd/tests/swap_lifecycle.rs index d2e2c0f..79bb6d3 100644 --- a/crates/deckard-signerd/tests/swap_lifecycle.rs +++ b/crates/deckard-signerd/tests/swap_lifecycle.rs @@ -97,17 +97,10 @@ async fn sign_order(client: &SignerClient, id: RequestId) -> SignOrderResult { } } -async fn resolve(client: &SignerClient, id: RequestId, approved: bool) { - assert_eq!( - client - .request(&SignerRequest::Resolve { - request_id: id, - approved, - }) - .await - .unwrap(), - SignerResponse::Ack - ); +/// Approve/deny over the authenticated control channel — a `Resolve` on the public socket is +/// refused (PRD-01). Sync (a quick socketpair round-trip); callers drop the `.await`. +fn resolve(d: &DaemonProc, id: RequestId, approved: bool) { + d.resolve(id, approved); } async fn revoke_all(client: &SignerClient) { @@ -152,7 +145,7 @@ async fn happy_path_propose_approve_sign_recovers_wallet() { let id = needs_approval_id(&client, &order).await; // Approve, then sign. - resolve(&client, id, true).await; + resolve(&d, id, true); let sig = match sign_order(&client, id).await { SignOrderResult::Signed { signature } => signature, other => panic!("expected Signed, got {other:?}"), @@ -184,7 +177,7 @@ async fn reject_path_resolve_false_then_sign_denied() { let order = order_for(wallet, sepolia_weth(), sepolia_cow(), 2_222_222); let id = needs_approval_id(&client, &order).await; - resolve(&client, id, false).await; + resolve(&d, id, false); match sign_order(&client, id).await { SignOrderResult::Denied { .. } => {} SignOrderResult::Signed { .. } => panic!("a user-denied order must NOT sign"), @@ -243,7 +236,7 @@ async fn toctou_approve_then_revoke_then_sign_denied_revoked() { // Approve BEFORE the STOP, then STOP, then attempt to sign — the sign-time revoked re-check // is the only thing standing between an approval and a signature. - resolve(&client, id, true).await; + resolve(&d, id, true); revoke_all(&client).await; match sign_order(&client, id).await { SignOrderResult::Denied { reason } => assert_eq!( @@ -269,7 +262,7 @@ async fn stop_attempts_cancel_of_a_signed_order_and_stays_responsive() { let order = order_for(wallet, sepolia_weth(), sepolia_cow(), 6_666_666); let id = needs_approval_id(&client, &order).await; - resolve(&client, id, true).await; + resolve(&d, id, true); // Sign it — now `signature.is_some()`, so STOP will SELECT it for an on-chain cancel. match sign_order(&client, id).await { SignOrderResult::Signed { .. } => {} diff --git a/docs/adr/0001-dapp-connectivity-architecture.md b/docs/adr/0001-dapp-connectivity-architecture.md index 14eb40d..19b9a18 100644 --- a/docs/adr/0001-dapp-connectivity-architecture.md +++ b/docs/adr/0001-dapp-connectivity-architecture.md @@ -160,6 +160,19 @@ clear-signing pattern. No new trust boundary is invented; we extend the one we h - PRD-01: `socketpair` inheritance vs `SCM_RIGHTS` fd-pass vs `0600` cookie — pick the most portable capability for macOS + Linux given `supervise.rs` already spawns the daemon (note the daemon-spawns-app vs app-spawns-daemon direction inversion). + - **Resolved → `socketpair` inheritance (app is the parent).** The app already `fork+exec`s the + daemon (`supervise.rs`), so it mints an `AF_UNIX` `socketpair` pre-spawn, hands the child one + end by fd inheritance (its number in `DECKARD_RESOLVE_FD`), and keeps the other as the + `ControlChannel`; the daemon honours `Resolve` only there (`daemon.rs::Channel`, + `server.rs::serve_control`). Portable on macOS + Linux with no peer-cred dependence for the + role decision — the fd *is* the unforgeable role proof that same-uid peer-cred (`SO_PEERCRED` + on Linux; `LOCAL_PEERCRED`, no pid, on macOS) cannot give. **`SCM_RIGHTS`** was rejected: it + fits a daemon-spawns-app or already-authenticated peer, but here authentication *is* the + problem (the daemon would hand the fd back over the very public socket any same-uid client can + open). **`0600` cookie** was rejected: a uid-readable secret doesn't split the same-uid + boundary and would need a wire field, breaking "no wire change." The one unavoidable `unsafe` + (`from_raw_fd`) lives daemon-side under a scoped, documented `#[allow]`; `deckard-core`'s + `#![forbid(unsafe_code)]` is untouched. - PRD-02: which `IntentKind`s to add (`SignMessage`, `SignTypedData`, and whether EIP-7702 `Delegate` is supported-behind-allowlist or refused outright). - PRD-04: the browser-reach mechanism for the owned bridge — native-messaging stdio host (preferred, diff --git a/docs/build/31-agent-quickstart.md b/docs/build/31-agent-quickstart.md index 3dac789..9f95b5d 100644 --- a/docs/build/31-agent-quickstart.md +++ b/docs/build/31-agent-quickstart.md @@ -126,6 +126,7 @@ error is to retry — for two of these (marked **do NOT retry**) that instinct i | `broadcast_failed: …` | The RPC refused the transaction; nothing was consumed. | Check the chain/RPC is up (`just demo-check`), then re-run from `deckard_shield`. | | `not_approved` | The request needs a human approval that hasn't happened. | Lower the amount under the per-tx cap, or a human edits `policy.json` (no approval card in this alpha). | | `user_denied` | A human said no. | Respect it; propose something different only if asked. | +| `resolve_not_authorized` | A `Resolve` (approval) was sent on the public proposer socket, which can't approve — only the Deckard app, over its private channel, can. | Don't try to self-approve. A human approves in the Deckard app (hold-to-confirm); the sidecar never gets a `resolve` tool. | | `chain_mismatch` | Sidecar and daemon disagree on the chain (e.g. demo sidecar → real daemon). | Re-run `deckard-mcp install --demo` and make sure `just demo` is what's running. | | `over_cap` | Over the cap with `require_approval = never` — nothing can authorize it. | Lower the amount under `per_tx_cap_wei` (read it with `deckard_policy_get`). | | `cap_exceeded` | Executing would pass the spending caps as re-checked at sign time. | Lower the amount or wait for the UTC-midnight rollover; re-read the policy for current numbers. |