diff --git a/crates/deckard-app/src/activity_view.rs b/crates/deckard-app/src/activity_view.rs index 3a6344f..4817fdf 100644 --- a/crates/deckard-app/src/activity_view.rs +++ b/crates/deckard-app/src/activity_view.rs @@ -48,7 +48,7 @@ use gpui_component::{ use deckard_contract::{ ActivityLifecycle, ActivityRecord, BreachedLimit, Intent, IntentKind, PendingPayloadView, - ProposalOrigin, RequestId, + ProposalOrigin, RequestId, SignMessage, SignMessageKind, }; use crate::money::money; @@ -145,6 +145,33 @@ fn payload_summary(payload: &PendingPayloadView, mask: bool) -> String { short_address(token) ) } + PendingPayloadView::Message(message) => message_summary(message), + } +} + +/// One-line summary for off-chain signatures. Plain language first; details live in the review. +fn message_summary(message: &SignMessage) -> String { + match &message.kind { + SignMessageKind::PersonalSign { .. } => format!("sign message from {}", message.origin), + SignMessageKind::TypedDataV4(review) => { + format!("sign typed data: {}", review.primary_type) + } + SignMessageKind::EthSign { .. } => "refuse raw hash signature".to_string(), + SignMessageKind::Authorization7702 { .. } => "refuse wallet delegation".to_string(), + } +} + +fn message_preview(bytes: &[u8]) -> String { + match std::str::from_utf8(bytes) { + Ok(text) => { + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() > 80 { + format!("{}…", collapsed.chars().take(80).collect::()) + } else { + collapsed + } + } + Err(_) => format!("{} bytes (not UTF-8)", bytes.len()), } } @@ -985,6 +1012,116 @@ impl Shell { .into_any_element(), ); } + PendingPayloadView::Message(message) => { + rows.push( + kv_mono_row("Origin", &message.origin, mono.clone(), fg, muted) + .into_any_element(), + ); + match &message.kind { + SignMessageKind::PersonalSign { message } => { + rows.push( + kv_mono_row("Type", "personal_sign", mono.clone(), fg, muted) + .into_any_element(), + ); + rows.push( + kv_mono_row( + "Message", + &message_preview(message), + mono.clone(), + fg, + muted, + ) + .into_any_element(), + ); + } + SignMessageKind::TypedDataV4(review) => { + rows.push( + kv_mono_row("Type", "eth_signTypedData_v4", mono.clone(), fg, muted) + .into_any_element(), + ); + rows.push( + kv_mono_row( + "Primary type", + &review.primary_type, + mono.clone(), + fg, + muted, + ) + .into_any_element(), + ); + if let Some(name) = review.domain_name.as_ref() { + rows.push( + kv_mono_row("Domain", name, mono.clone(), fg, muted) + .into_any_element(), + ); + } + if let Some(chain_id) = review.domain_chain_id { + rows.push( + kv_mono_row( + "Domain chain", + &chain_id.to_string(), + mono.clone(), + fg, + muted, + ) + .into_any_element(), + ); + } + if let Some(contract) = review.verifying_contract.as_ref() { + rows.push( + kv_mono_row( + "Contract", + &short_address(contract), + mono.clone(), + fg, + muted, + ) + .into_any_element(), + ); + } + rows.push( + kv_mono_row( + "Digest", + &format!("{:#x}", review.digest), + mono.clone(), + fg, + muted, + ) + .into_any_element(), + ); + } + SignMessageKind::EthSign { digest } => { + rows.push( + kv_mono_row("Type", "eth_sign", mono.clone(), fg, muted) + .into_any_element(), + ); + rows.push( + kv_mono_row("Digest", &format!("{digest:#x}"), mono.clone(), fg, muted) + .into_any_element(), + ); + } + SignMessageKind::Authorization7702 { delegate, nonce } => { + rows.push( + kv_mono_row("Type", "EIP-7702 authorization", mono.clone(), fg, muted) + .into_any_element(), + ); + rows.push( + kv_mono_row( + "Delegate", + &short_address(delegate), + mono.clone(), + fg, + muted, + ) + .into_any_element(), + ); + rows.push( + kv_mono_row("Nonce", &nonce.to_string(), mono.clone(), fg, muted) + .into_any_element(), + ); + } + } + } } // The breached boundary — the real one (per-tx vs daily vs allow-list), stated once. diff --git a/crates/deckard-contract/src/deny_reasons.rs b/crates/deckard-contract/src/deny_reasons.rs index d99acce..db45623 100644 --- a/crates/deckard-contract/src/deny_reasons.rs +++ b/crates/deckard-contract/src/deny_reasons.rs @@ -54,6 +54,12 @@ pub const ZERO_AMOUNT: &str = "zero_amount"; pub const OFF_SWAP_LIST: &str = "off_swap_list"; /// Swap order `valid_to` is more than 24h in the future. pub const VALID_TO_TOO_FAR: &str = "valid_to_too_far"; +/// Typed-data domain chain id does not match the active signer chain. +pub const CHAINID_MISMATCH: &str = "chainid_mismatch"; +/// Raw `eth_sign` hash signing is refused; use reviewed typed/personal signing instead. +pub const ETH_SIGN_REFUSED: &str = "eth_sign_refused"; +/// EIP-7702 delegation authorization is refused until a reviewed allowlist path exists. +pub const DELEGATION_REFUSED: &str = "delegation_refused"; // ─────────────────────── Daemon process-level ─────────────────────── // Process-state pre-checks the pure policy can't express (`deckard-signerd`); they run @@ -119,6 +125,10 @@ pub const APPROVE_NO_MATCHING_ORDER: &str = "approve_no_matching_order"; pub const ALREADY_SIGNED: &str = "already_signed"; /// The request id refers to a Tx payload where an Order was required (or vice versa). pub const NOT_AN_ORDER: &str = "not_an_order"; +/// Request id exists, but it is not a message-signing payload. +pub const NOT_A_MESSAGE: &str = "not_a_message"; +/// Request id exists, but it is not an executable/broadcastable transaction payload. +pub const NOT_A_TRANSACTION: &str = "not_a_transaction"; /// The `MockSigner` used by the MCP test harness does not implement swaps. **Test surface /// only** — never minted by a real daemon, so it is intentionally absent from the agent docs /// table. diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index cf50145..839347b 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -26,6 +26,7 @@ pub mod clear_signing; pub mod decision; pub mod deny_reasons; pub mod intent; +pub mod message_signing; pub mod mock; pub mod policy; pub mod read_status; @@ -41,13 +42,14 @@ pub use clear_signing::{ }; pub use decision::{Decision, RequestId}; pub use intent::{Intent, IntentKind}; +pub use message_signing::{MessageSigningRisk, SignMessage, SignMessageKind, TypedDataReview}; pub use mock::MockSigner; -pub use policy::{evaluate, evaluate_order, ApprovalMode, Policy}; +pub use policy::{evaluate, evaluate_message, evaluate_order, ApprovalMode, Policy}; pub use read_status::ReadStatus; pub use rpc::{ ActivityLifecycle, ActivityRecord, ApprovalStatus, BalanceReport, BreachedLimit, ExecuteResult, - PendingPayloadView, PendingRecord, ProposalOrigin, RailgunViewGrant, SignOrderResult, - SignerRequest, SignerResponse, StatusView, UnlockOutcome, + PendingPayloadView, PendingRecord, ProposalOrigin, RailgunViewGrant, SignMessageResult, + SignOrderResult, SignerRequest, SignerResponse, StatusView, UnlockOutcome, }; pub use shield_status::ShieldStatus; pub use signer::Signer; @@ -126,6 +128,22 @@ mod roundtrip_tests { } } + fn sample_message() -> SignMessage { + SignMessage { + chain_id: 11155111, + origin: "https://example.test".into(), + kind: SignMessageKind::TypedDataV4(TypedDataReview { + domain_name: Some("Permit2".into()), + domain_version: Some("1".into()), + domain_chain_id: Some(11155111), + verifying_contract: Some(Address::repeat_byte(0x22)), + primary_type: "PermitSingle".into(), + digest: B256::repeat_byte(0x42), + risks: vec![MessageSigningRisk::PermitLike], + }), + } + } + #[test] fn intent_and_kind_roundtrip() { for kind in [ @@ -224,6 +242,13 @@ mod roundtrip_tests { order: sample_swap_order(), origin: ProposalOrigin::Agent, }); + roundtrip(&SignerRequest::ProposeMessage { + message: sample_message(), + origin: ProposalOrigin::Agent, + }); + roundtrip(&SignerRequest::SignMessage { + request_id: B256::repeat_byte(0x09), + }); roundtrip(&SignerRequest::SignOrder { request_id: B256::repeat_byte(0x06), }); @@ -305,6 +330,12 @@ mod roundtrip_tests { roundtrip(&SignerResponse::SignOrder(SignOrderResult::Denied { reason: "not_approved".into(), })); + roundtrip(&SignerResponse::SignMessage(SignMessageResult::Signed { + signature: Bytes::from(vec![0xEF_u8; 65]), + })); + roundtrip(&SignerResponse::SignMessage(SignMessageResult::Denied { + reason: "not_approved".into(), + })); roundtrip(&SignerResponse::Pending(vec![ PendingRecord { request_id: B256::repeat_byte(0x01), @@ -320,6 +351,13 @@ mod roundtrip_tests { remaining_ms: 0, origin: ProposalOrigin::App, }, + PendingRecord { + request_id: B256::repeat_byte(0x03), + status: ApprovalStatus::Pending, + payload: PendingPayloadView::Message(sample_message()), + remaining_ms: 60_000, + origin: ProposalOrigin::Agent, + }, ])); } @@ -426,9 +464,17 @@ mod roundtrip_tests { roundtrip(&SignOrderResult::Denied { reason: "revoked".into(), }); + roundtrip(&SignMessageResult::Signed { + signature: Bytes::from(vec![0xEF_u8; 65]), + }); + roundtrip(&SignMessageResult::Denied { + reason: "revoked".into(), + }); + roundtrip(&sample_message()); roundtrip(&PendingPayloadView::Tx(sample_intent(IntentKind::Send))); roundtrip(&PendingPayloadView::Order(sample_swap_order())); + roundtrip(&PendingPayloadView::Message(sample_message())); roundtrip(&PendingPayloadView::Approve { token: Address::repeat_byte(0xA1), spender: Address::repeat_byte(0xC9), diff --git a/crates/deckard-contract/src/message_signing.rs b/crates/deckard-contract/src/message_signing.rs new file mode 100644 index 0000000..563dabe --- /dev/null +++ b/crates/deckard-contract/src/message_signing.rs @@ -0,0 +1,71 @@ +//! Message-signing wire types for clear-signing v2. +//! +//! These are reviewed payloads, not browser JSON-RPC frames. A future EIP-1193 bridge +//! parses `personal_sign` / `eth_signTypedData_v4` into this bounded model before the +//! daemon proposes it. The daemon signs only a stored, approved payload. + +use alloy_primitives::{Address, Bytes, B256}; +use serde::{Deserialize, Serialize}; + +/// A human-reviewed off-chain signature request. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SignMessage { + /// The active signer chain. For typed data this must match `domain_chain_id` when present. + pub chain_id: u64, + /// Unverified requester label/origin. Display-only; never a trust root. + pub origin: String, + pub kind: SignMessageKind, +} + +/// The message family. `EthSign` and `Authorization7702` are explicit so the policy can refuse +/// them with stable reasons instead of letting them masquerade as harmless bytes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SignMessageKind { + /// EIP-191 personal message bytes (`personal_sign`). + PersonalSign { message: Bytes }, + /// EIP-712 typed-data review payload (`eth_signTypedData_v4`) after bounded parsing. + TypedDataV4(TypedDataReview), + /// Raw hash signing (`eth_sign`) — always refused. + EthSign { digest: B256 }, + /// EIP-7702 authorization — always refused until a dedicated allow path exists. + Authorization7702 { delegate: Address, nonce: u64 }, +} + +/// Minimal reviewed EIP-712 surface. The full typed-data JSON remains outside the daemon wire; +/// the parser/resolver feeds the digest and the human-facing domain/type facts here. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TypedDataReview { + pub domain_name: Option, + pub domain_version: Option, + pub domain_chain_id: Option, + pub verifying_contract: Option
, + pub primary_type: String, + /// Final EIP-712 digest (`"\x19\x01" || domainSeparator || hashStruct(message)`). + pub digest: B256, + /// Display warnings derived by the bounded parser / descriptor resolver. + #[serde(default)] + pub risks: Vec, +} + +/// Warnings the clear-signing card can render without trusting arbitrary descriptor text. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum MessageSigningRisk { + PermitLike, + OwnershipChange, + SeaportOrder, + UnknownVerifyingContract, + DescriptorMissing, + DescriptorInvalid, +} + +impl SignMessage { + #[must_use] + pub fn signing_digest(&self) -> Option { + match &self.kind { + SignMessageKind::PersonalSign { .. } => None, + SignMessageKind::TypedDataV4(review) => Some(review.digest), + SignMessageKind::EthSign { digest } => Some(*digest), + SignMessageKind::Authorization7702 { .. } => None, + } + } +} diff --git a/crates/deckard-contract/src/policy.rs b/crates/deckard-contract/src/policy.rs index 0d50779..c712264 100644 --- a/crates/deckard-contract/src/policy.rs +++ b/crates/deckard-contract/src/policy.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::decision::{Decision, RequestId}; use crate::deny_reasons; use crate::intent::{Intent, IntentKind}; +use crate::message_signing::{SignMessage, SignMessageKind}; use crate::swap_order::SwapOrder; /// The agent-readable policy. All caps are in wei. @@ -155,6 +156,42 @@ pub fn evaluate_order(order: &SwapOrder, policy: &Policy, wallet: Address, now: } } +/// The message-signing decision function — pure, like [`evaluate`] and [`evaluate_order`]. +/// Messages never auto-allow in v1: any safe request is held for human approval. The `wallet` +/// argument is present so the parity signature has the daemon-bound account available as the +/// policy grows; it is intentionally unused by the first pass. +pub fn evaluate_message(message: &SignMessage, policy: &Policy, _wallet: Address) -> Decision { + if policy.revoked { + return Decision::Deny { + reason: deny_reasons::REVOKED.into(), + }; + } + match &message.kind { + SignMessageKind::PersonalSign { .. } => Decision::NeedsApproval { + request_id: RequestId::ZERO, + }, + SignMessageKind::TypedDataV4(review) => { + if review + .domain_chain_id + .is_some_and(|chain_id| chain_id != message.chain_id) + { + return Decision::Deny { + reason: deny_reasons::CHAINID_MISMATCH.into(), + }; + } + Decision::NeedsApproval { + request_id: RequestId::ZERO, + } + } + SignMessageKind::EthSign { .. } => Decision::Deny { + reason: deny_reasons::ETH_SIGN_REFUSED.into(), + }, + SignMessageKind::Authorization7702 { .. } => Decision::Deny { + reason: deny_reasons::DELEGATION_REFUSED.into(), + }, + } +} + /// Shape check: does the calldata match the kind? The real Railgun adapter calldata is /// validated downstream (`10-kohaku-shield.md`); this only enforces the coarse invariant /// the policy gate relies on. @@ -373,3 +410,110 @@ mod evaluate_order_tests { ); } } + +#[cfg(test)] +mod message_signing_tests { + use super::*; + use crate::{MessageSigningRisk, SignMessage, SignMessageKind, TypedDataReview}; + use alloy_primitives::B256; + + const CHAIN_ID: u64 = 11155111; + + fn wallet() -> Address { + Address::repeat_byte(0x11) + } + + fn base_policy() -> Policy { + Policy { + per_tx_cap_wei: U256::from(50u64), + daily_cap_wei: U256::from(1000u64), + spent_today_wei: U256::ZERO, + allow_to: vec![], + auto_shield_min_wei: U256::from(10u64), + require_approval: ApprovalMode::Never, + revoked: false, + allow_swap_tokens: vec![], + } + } + + fn personal_message() -> SignMessage { + SignMessage { + chain_id: CHAIN_ID, + origin: "https://example.test".into(), + kind: SignMessageKind::PersonalSign { + message: b"Sign in to Deckard".as_slice().into(), + }, + } + } + + fn typed_message(chain_id: u64) -> SignMessage { + SignMessage { + chain_id: CHAIN_ID, + origin: "https://example.test".into(), + kind: SignMessageKind::TypedDataV4(TypedDataReview { + domain_name: Some("Permit2".into()), + domain_version: Some("1".into()), + domain_chain_id: Some(chain_id), + verifying_contract: Some(Address::repeat_byte(0x22)), + primary_type: "PermitSingle".into(), + digest: B256::repeat_byte(0x42), + risks: vec![MessageSigningRisk::PermitLike], + }), + } + } + + #[test] + fn personal_sign_always_needs_approval() { + assert_eq!( + evaluate_message(&personal_message(), &base_policy(), wallet()), + Decision::NeedsApproval { + request_id: RequestId::ZERO + } + ); + } + + #[test] + fn typed_data_chainid_mismatch_denies() { + assert_eq!( + evaluate_message(&typed_message(1), &base_policy(), wallet()), + Decision::Deny { + reason: deny_reasons::CHAINID_MISMATCH.into() + } + ); + } + + #[test] + fn eth_sign_is_refused() { + let message = SignMessage { + chain_id: CHAIN_ID, + origin: "https://example.test".into(), + kind: SignMessageKind::EthSign { + digest: B256::repeat_byte(0x33), + }, + }; + assert_eq!( + evaluate_message(&message, &base_policy(), wallet()), + Decision::Deny { + reason: deny_reasons::ETH_SIGN_REFUSED.into() + } + ); + } + + #[test] + fn eip7702_delegation_refused() { + let message = SignMessage { + chain_id: CHAIN_ID, + origin: "https://example.test".into(), + kind: SignMessageKind::Authorization7702 { + delegate: Address::repeat_byte(0x44), + nonce: 7, + }, + }; + assert_eq!( + evaluate_message(&message, &base_policy(), wallet()), + Decision::Deny { + reason: deny_reasons::DELEGATION_REFUSED.into() + } + ); + } +} diff --git a/crates/deckard-contract/src/rpc.rs b/crates/deckard-contract/src/rpc.rs index 0024083..4c34840 100644 --- a/crates/deckard-contract/src/rpc.rs +++ b/crates/deckard-contract/src/rpc.rs @@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::decision::{Decision, RequestId}; use crate::intent::Intent; +use crate::message_signing::SignMessage; use crate::policy::Policy; use crate::read_status::ReadStatus; use crate::swap_order::SwapOrder; @@ -74,8 +75,15 @@ pub enum SignerRequest { order: SwapOrder, origin: ProposalOrigin, }, + /// Propose an off-chain message signature (policy check only, NO signing) → [`Decision`]. + ProposeMessage { + message: SignMessage, + origin: ProposalOrigin, + }, /// Sign a stored, approved order's EIP-712 digest → [`SignOrderResult`]. No HTTP. SignOrder { request_id: RequestId }, + /// Sign a stored, approved message → [`SignMessageResult`]. No HTTP, no broadcast. + SignMessage { request_id: RequestId }, /// Broadcast an `invalidateOrder` cancel for a stored order → [`ExecuteResult`]. CancelOrder { request_id: RequestId }, /// List all in-flight pending records WITH payloads (the GUI approval inbox) → [`SignerResponse::Pending`]. @@ -107,6 +115,8 @@ pub enum SignerResponse { RailgunView(RailgunViewGrant), /// Reply to `SignOrder`. SignOrder(SignOrderResult), + /// Reply to `SignMessage`. + SignMessage(SignMessageResult), /// Reply to `PendingList`: every in-flight record with its full payload. Pending(Vec), /// Reply to `ActivityFeed`: the activity ledger, newest-first. @@ -185,6 +195,13 @@ pub enum SignOrderResult { Denied { reason: String }, } +/// Result of `sign_message`. `signature` is a 65-byte ECDSA signature. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum SignMessageResult { + Signed { signature: Bytes }, + Denied { reason: String }, +} + /// One pending record for the GUI inbox (child #25 renders these). Carries the full payload. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PendingRecord { @@ -221,6 +238,7 @@ pub struct StatusView { pub enum PendingPayloadView { Tx(Intent), Order(SwapOrder), + Message(SignMessage), Approve { token: Address, spender: Address, diff --git a/crates/deckard-contract/tests/deny_vocabulary.rs b/crates/deckard-contract/tests/deny_vocabulary.rs index 20262a1..83424ee 100644 --- a/crates/deckard-contract/tests/deny_vocabulary.rs +++ b/crates/deckard-contract/tests/deny_vocabulary.rs @@ -72,6 +72,9 @@ const FROZEN: &[&str] = &[ r::ZERO_AMOUNT, r::OFF_SWAP_LIST, r::VALID_TO_TOO_FAR, + r::CHAINID_MISMATCH, + r::ETH_SIGN_REFUSED, + r::DELEGATION_REFUSED, // daemon process-level r::LOCKED, r::CHAIN_MISMATCH, @@ -96,6 +99,8 @@ const FROZEN: &[&str] = &[ r::APPROVE_NO_MATCHING_ORDER, r::ALREADY_SIGNED, r::NOT_AN_ORDER, + r::NOT_A_MESSAGE, + r::NOT_A_TRANSACTION, r::SWAP_UNSUPPORTED_IN_MOCK, // dynamic prefixes r::RAILGUN_KEYS, @@ -430,7 +435,7 @@ fn frozen_set_matches_module_exports() { fn frozen_set_is_exactly_documented() { assert_eq!( FROZEN.len(), - 36, + 41, "added/removed a Deny tag? update FROZEN, deny_reasons.rs, and the docs table" ); diff --git a/crates/deckard-mcp/tests/common/mod.rs b/crates/deckard-mcp/tests/common/mod.rs index d6d8095..867c560 100644 --- a/crates/deckard-mcp/tests/common/mod.rs +++ b/crates/deckard-mcp/tests/common/mod.rs @@ -22,8 +22,8 @@ use tokio::process::{Child, ChildStdin, Command}; use deckard_contract::{ evaluate, ActivityLifecycle, ApprovalMode, ApprovalStatus, Decision, ExecuteResult, Intent, - Policy, RailgunViewGrant, ReadStatus, RequestId, SignOrderResult, SignerRequest, - SignerResponse, StatusView, UnlockOutcome, + Policy, RailgunViewGrant, ReadStatus, RequestId, SignMessageResult, SignOrderResult, + SignerRequest, SignerResponse, StatusView, UnlockOutcome, }; use deckard_signerd::{frame, request_id_for}; @@ -253,6 +253,16 @@ impl MockState { SignerRequest::SignOrder { .. } => SignerResponse::SignOrder(SignOrderResult::Denied { reason: "swap_unsupported_in_mock".into(), }), + // Message-signing is intentionally outside the current MCP acceptance mock: the + // browser bridge will own dapp-origin message requests. Stay exhaustive and fail closed. + SignerRequest::ProposeMessage { .. } => SignerResponse::Decision(Decision::Deny { + reason: "message_signing_unsupported_in_mock".into(), + }), + SignerRequest::SignMessage { .. } => { + SignerResponse::SignMessage(SignMessageResult::Denied { + reason: "message_signing_unsupported_in_mock".into(), + }) + } SignerRequest::CancelOrder { .. } => SignerResponse::Execute(ExecuteResult::Denied { reason: "swap_unsupported_in_mock".into(), }), diff --git a/crates/deckard-signerd/src/client.rs b/crates/deckard-signerd/src/client.rs index 90d3827..00e98ed 100644 --- a/crates/deckard-signerd/src/client.rs +++ b/crates/deckard-signerd/src/client.rs @@ -13,12 +13,14 @@ use zeroize::Zeroize; use deckard_contract::{ ActivityRecord, ApprovalStatus, Decision, ExecuteResult, Intent, PendingRecord, ProposalOrigin, - RailgunViewGrant, RequestId, SignOrderResult, SignerRequest, SignerResponse, SwapOrder, - UnlockOutcome, + RailgunViewGrant, RequestId, SignMessage, SignMessageResult, SignOrderResult, SignerRequest, + SignerResponse, SwapOrder, UnlockOutcome, }; use crate::frame; -use crate::request_id::{request_id_for, request_id_for_order}; +use crate::request_id::{ + request_id_for, request_id_for_message as request_id_for_sign_message, request_id_for_order, +}; /// How long to keep retrying `connect` before giving up — covers the brief window where the /// app has spawned the daemon but it hasn't bound the socket yet. @@ -211,6 +213,70 @@ impl SignerClient { request_id_for(intent) } + // --- message signing helpers ------------------------------------------------------- + + /// Propose an off-chain message signature → a `Decision`. Message signatures never + /// auto-allow in v1; a safe request is held for human approval. + pub async fn propose_message( + &self, + message: &SignMessage, + origin: ProposalOrigin, + ) -> anyhow::Result { + match self + .request(&SignerRequest::ProposeMessage { + message: message.clone(), + origin, + }) + .await? + { + SignerResponse::Decision(d) => Ok(d), + other => Err(unexpected("ProposeMessage", other)), + } + } + + /// Blocking [`propose_message`](Self::propose_message). + pub fn propose_message_blocking( + &self, + message: &SignMessage, + origin: ProposalOrigin, + ) -> anyhow::Result { + match self.request_blocking(&SignerRequest::ProposeMessage { + message: message.clone(), + origin, + })? { + SignerResponse::Decision(d) => Ok(d), + other => Err(unexpected("ProposeMessage", other)), + } + } + + /// Sign a stored, human-approved message → its 65-byte ECDSA signature. + pub async fn sign_message(&self, request_id: RequestId) -> anyhow::Result { + match self + .request(&SignerRequest::SignMessage { request_id }) + .await? + { + SignerResponse::SignMessage(r) => Ok(r), + other => Err(unexpected("SignMessage", other)), + } + } + + /// Blocking [`sign_message`](Self::sign_message). + pub fn sign_message_blocking( + &self, + request_id: RequestId, + ) -> anyhow::Result { + match self.request_blocking(&SignerRequest::SignMessage { request_id })? { + SignerResponse::SignMessage(r) => Ok(r), + other => Err(unexpected("SignMessage", other)), + } + } + + /// The deterministic request id for a message — lets clients correlate a proposal with + /// pending/activity rows without re-reading the whole inbox. + pub fn request_id_for_message(message: &SignMessage) -> RequestId { + request_id_for_sign_message(message) + } + // --- swap order helpers (the agent proposes/signs/cancels CoW orders) ------------------ /// Propose a swap order → a `Decision`. A valid order is always `NeedsApproval` (swaps diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index e1b8809..9dab1c3 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -20,10 +20,11 @@ use tokio::sync::Mutex as AsyncMutex; use zeroize::Zeroizing; use deckard_contract::{ - deny_reasons, evaluate, evaluate_order, ActivityLifecycle, ActivityRecord, ApprovalStatus, - BalanceReport, BreachedLimit, Decision, ExecuteResult, Intent, IntentKind, PendingPayloadView, - PendingRecord, Policy, ProposalOrigin, ReadStatus, RequestId, SignOrderResult, SignerRequest, - SignerResponse, StatusView, SwapOrder, UnlockOutcome, + deny_reasons, evaluate, evaluate_message, evaluate_order, ActivityLifecycle, ActivityRecord, + ApprovalStatus, BalanceReport, BreachedLimit, Decision, ExecuteResult, Intent, IntentKind, + PendingPayloadView, PendingRecord, Policy, ProposalOrigin, ReadStatus, RequestId, SignMessage, + SignMessageKind, SignMessageResult, SignOrderResult, SignerRequest, SignerResponse, StatusView, + SwapOrder, UnlockOutcome, }; // Only the `shield`-gated view-grant handler constructs this; an unconditional import would // warn in the no-default-features build (e.g. deckard-mcp's dependency edge). @@ -33,7 +34,7 @@ use deckard_core::{UnlockedVault, Vault}; use crate::config::Config; use crate::policy_store::{self, current_utc_day}; -use crate::request_id::{request_id_for, request_id_for_order}; +use crate::request_id::{request_id_for, request_id_for_message, request_id_for_order}; use crate::signing; use crate::spend_store::SpendStore; @@ -99,6 +100,7 @@ enum VaultState { enum PendingPayload { Tx(Intent), Order(SwapOrder), + Message(SignMessage), } /// One tracked proposal. `status` is the wire-visible approval state; `broadcast` is `Some` @@ -360,9 +362,15 @@ impl Daemon { SignerRequest::ProposeOrder { order, origin } => { SignerResponse::Decision(self.propose_order(&order, origin)) } + SignerRequest::ProposeMessage { message, origin } => { + SignerResponse::Decision(self.propose_message(&message, origin)) + } SignerRequest::SignOrder { request_id } => { SignerResponse::SignOrder(self.sign_order(request_id).await) } + SignerRequest::SignMessage { request_id } => { + SignerResponse::SignMessage(self.sign_message(request_id).await) + } SignerRequest::CancelOrder { request_id } => { SignerResponse::Execute(self.cancel_order(request_id).await) } @@ -738,7 +746,7 @@ impl Daemon { && order.sell_token == intent.to && order.sell_amount == amount } - PendingPayload::Tx(_) => false, + PendingPayload::Tx(_) | PendingPayload::Message(_) => false, }); if !has_matching_order { return Some(Decision::Deny { @@ -836,6 +844,70 @@ impl Daemon { } } + /// Propose an off-chain message signature. Message requests NEVER auto-allow in v1: + /// a valid request is stored `Pending` and must be resolved by the human control channel + /// before `sign_message` releases a signature. + fn propose_message(&mut self, message: &SignMessage, origin: ProposalOrigin) -> Decision { + self.rollover(); + self.expire_stale(); + + if message.chain_id != self.cfg.chain_id { + return Decision::Deny { + reason: deny_reasons::CHAIN_MISMATCH.into(), + }; + } + let wallet = match &self.state { + VaultState::Unlocked { address, .. } => *address, + VaultState::Locked => { + return Decision::Deny { + reason: deny_reasons::LOCKED.into(), + } + } + }; + + let id = request_id_for_message(message); + if let Some(existing) = self.requests.get(&id) { + return match &existing.status { + _ if existing.signature.is_some() => Decision::Deny { + reason: deny_reasons::ALREADY_SIGNED.into(), + }, + ApprovalStatus::Pending => Decision::NeedsApproval { request_id: id }, + ApprovalStatus::Allowed => Decision::Allow, + ApprovalStatus::Denied { reason } => Decision::Deny { + reason: reason.clone(), + }, + ApprovalStatus::Expired => Decision::Deny { + reason: deny_reasons::EXPIRED.into(), + }, + }; + } + + match evaluate_message(message, &self.policy, wallet) { + deny @ Decision::Deny { .. } => deny, + Decision::NeedsApproval { .. } | Decision::Allow => { + let seq = self.next_seq(); + self.requests.insert( + id, + PendingReq { + payload: PendingPayload::Message(message.clone()), + status: ApprovalStatus::Pending, + expires_at: Instant::now() + self.approval_ttl, + broadcast: None, + signature: None, + approved: false, + origin, + created_ms: now_ms(), + seq, + breached: BreachedLimit::None, + cancelled: false, + auto_allowed: false, + }, + ); + Decision::NeedsApproval { request_id: id } + } + } + } + /// Sign a stored, approved swap order's EIP-712 digest → a 65-byte signature. NO HTTP, NO /// broadcast (the app/MCP posts the signed order to the CoW orderbook). Re-checks `revoked` /// at sign time (TOCTOU) so an order approved before a STOP is still refused. An order @@ -866,7 +938,7 @@ impl Daemon { }; let order = match &req.payload { PendingPayload::Order(order) => order, - PendingPayload::Tx(_) => { + PendingPayload::Tx(_) | PendingPayload::Message(_) => { return SignOrderResult::Denied { reason: deny_reasons::NOT_AN_ORDER.into(), } @@ -931,6 +1003,103 @@ impl Daemon { SignOrderResult::Signed { signature } } + /// Sign a stored, approved off-chain message. No network and no broadcast. Re-checks + /// `revoked` at sign time so STOP wins even after approval. + async fn sign_message(&mut self, request_id: RequestId) -> SignMessageResult { + self.rollover(); + self.expire_stale(); + + let (message, scalar) = { + let vault = match &self.state { + VaultState::Locked => { + return SignMessageResult::Denied { + reason: deny_reasons::REVOKED.into(), + } + } + VaultState::Unlocked { vault, .. } => vault, + }; + let req = match self.requests.get(&request_id) { + None => { + return SignMessageResult::Denied { + reason: deny_reasons::UNKNOWN_REQUEST.into(), + } + } + Some(req) => req, + }; + let message = match &req.payload { + PendingPayload::Message(message) => message, + PendingPayload::Tx(_) | PendingPayload::Order(_) => { + return SignMessageResult::Denied { + reason: deny_reasons::NOT_A_MESSAGE.into(), + } + } + }; + if req.signature.is_some() { + return SignMessageResult::Denied { + reason: deny_reasons::ALREADY_SIGNED.into(), + }; + } + match &req.status { + ApprovalStatus::Allowed => {} + ApprovalStatus::Pending => { + return SignMessageResult::Denied { + reason: deny_reasons::NOT_APPROVED.into(), + } + } + ApprovalStatus::Denied { reason } => { + return SignMessageResult::Denied { + reason: reason.clone(), + } + } + ApprovalStatus::Expired => { + return SignMessageResult::Denied { + reason: deny_reasons::EXPIRED.into(), + } + } + } + if self.policy.revoked { + return SignMessageResult::Denied { + reason: deny_reasons::REVOKED.into(), + }; + } + let signer = match vault.account_signer(0) { + Ok(s) => s, + Err(e) => { + return SignMessageResult::Denied { + reason: deny_reasons::signer_error(one_line(&e)), + } + } + }; + (message.clone(), Zeroizing::new(signer.to_bytes().0)) + }; + + let sig = match &message.kind { + SignMessageKind::PersonalSign { message } => { + signing::sign_personal_message(scalar.as_slice(), message) + } + SignMessageKind::TypedDataV4(review) => { + signing::sign_message_digest(scalar.as_slice(), review.digest) + } + SignMessageKind::EthSign { .. } => Err(anyhow::anyhow!(deny_reasons::ETH_SIGN_REFUSED)), + SignMessageKind::Authorization7702 { .. } => { + Err(anyhow::anyhow!(deny_reasons::DELEGATION_REFUSED)) + } + }; + let sig = match sig { + Ok(sig) => sig, + Err(e) => { + return SignMessageResult::Denied { + reason: deny_reasons::sign_failed(one_line(&e)), + } + } + }; + let signature = Bytes::from(sig.to_vec()); + if let Some(req) = self.requests.get_mut(&request_id) { + req.signature = Some(signature.clone()); + } + SignMessageResult::Signed { signature } + } + /// Broadcast an `invalidateOrder` cancel for a stored swap order (an on-chain cancellation /// at the GPv2 settlement contract). Requires an unlocked wallet (the owner must sign the /// cancel). Used both by an explicit `CancelOrder` and by the STOP pass (see [`stop`]). @@ -974,7 +1143,7 @@ impl Daemon { let order = match self.requests.get(&request_id) { Some(req) => match &req.payload { PendingPayload::Order(order) => order, - PendingPayload::Tx(_) => { + PendingPayload::Tx(_) | PendingPayload::Message(_) => { return ExecuteResult::Denied { reason: deny_reasons::NOT_AN_ORDER.into(), } @@ -1118,9 +1287,9 @@ impl Daemon { // this table can't reach `execute` through any normal flow, but guard defensively. let intent = match &req.payload { PendingPayload::Tx(intent) => intent, - PendingPayload::Order(_) => { + PendingPayload::Order(_) | PendingPayload::Message(_) => { return ExecuteResult::Denied { - reason: deny_reasons::NOT_AN_ORDER.into(), + reason: deny_reasons::NOT_A_TRANSACTION.into(), } } }; @@ -1533,7 +1702,7 @@ fn select_orders_to_cancel(requests: &HashMap, now: u64) .filter_map(|(id, req)| { let order = match &req.payload { PendingPayload::Order(o) => o, - PendingPayload::Tx(_) => return None, + PendingPayload::Tx(_) | PendingPayload::Message(_) => return None, }; let signed = req.signature.is_some(); let not_cancelled = req.broadcast.is_none(); @@ -1549,6 +1718,7 @@ fn select_orders_to_cancel(requests: &HashMap, now: u64) fn payload_view(payload: &PendingPayload) -> PendingPayloadView { match payload { PendingPayload::Order(order) => PendingPayloadView::Order(order.clone()), + PendingPayload::Message(message) => PendingPayloadView::Message(message.clone()), PendingPayload::Tx(intent) => match deckard_core::decode_approve(&intent.calldata) { Some((spender, amount)) => PendingPayloadView::Approve { token: intent.to, diff --git a/crates/deckard-signerd/src/request_id.rs b/crates/deckard-signerd/src/request_id.rs index 0478ba5..e9864f2 100644 --- a/crates/deckard-signerd/src/request_id.rs +++ b/crates/deckard-signerd/src/request_id.rs @@ -15,7 +15,7 @@ use alloy_primitives::keccak256; -use deckard_contract::{Intent, IntentKind, RequestId, SwapOrder}; +use deckard_contract::{Intent, IntentKind, RequestId, SignMessage, SignMessageKind, SwapOrder}; /// Deterministic request id for an intent. Fixed-width fields first, variable `calldata` /// last, so no field boundary is ambiguous. @@ -64,6 +64,36 @@ pub fn request_id_for_order(order: &SwapOrder) -> RequestId { keccak256(&buf) } +/// Deterministic request id for an off-chain message-signing request. The `0x03` +/// discriminator is disjoint from transaction (`chain_id` prefix) and order (`0x02`) ids. +pub fn request_id_for_message(message: &SignMessage) -> RequestId { + let mut buf = Vec::with_capacity(128); + buf.push(0x03); + buf.extend_from_slice(&message.chain_id.to_be_bytes()); + buf.extend_from_slice(message.origin.as_bytes()); + buf.push(0x00); + match &message.kind { + SignMessageKind::PersonalSign { message } => { + buf.push(0x01); + buf.extend_from_slice(message); + } + SignMessageKind::TypedDataV4(review) => { + buf.push(0x02); + buf.extend_from_slice(review.digest.as_slice()); + } + SignMessageKind::EthSign { digest } => { + buf.push(0x03); + buf.extend_from_slice(digest.as_slice()); + } + SignMessageKind::Authorization7702 { delegate, nonce } => { + buf.push(0x04); + buf.extend_from_slice(delegate.as_slice()); + buf.extend_from_slice(&nonce.to_be_bytes()); + } + } + keccak256(&buf) +} + #[cfg(test)] mod tests { use super::*; @@ -94,6 +124,16 @@ mod tests { } } + fn personal_message(text: &str) -> SignMessage { + SignMessage { + chain_id: 31337, + origin: "https://example.test".into(), + kind: SignMessageKind::PersonalSign { + message: Bytes::from(text.as_bytes().to_vec()), + }, + } + } + #[test] fn deterministic_and_nonzero() { let id = request_id_for(&send(100)); @@ -178,4 +218,18 @@ mod tests { "order vs intent must be disjoint" ); } + + #[test] + fn message_id_deterministic_and_disjoint() { + let id = request_id_for_message(&personal_message("hello")); + assert_eq!(id, request_id_for_message(&personal_message("hello"))); + assert_ne!(id, RequestId::ZERO); + assert_ne!(id, request_id_for(&send(100)), "message vs intent"); + assert_ne!(id, request_id_for_order(&order(100)), "message vs order"); + assert_ne!( + id, + request_id_for_message(&personal_message("goodbye")), + "message bytes" + ); + } } diff --git a/crates/deckard-signerd/src/signing.rs b/crates/deckard-signerd/src/signing.rs index bb8adbc..d524024 100644 --- a/crates/deckard-signerd/src/signing.rs +++ b/crates/deckard-signerd/src/signing.rs @@ -96,11 +96,36 @@ pub async fn broadcast_intent( /// The byte layout is built with `split_at_mut`/`copy_from_slice` (no index expressions, to /// match the trust-core lint posture even though this crate isn't under that deny list). pub fn sign_order_digest(scalar: &[u8], digest: B256) -> anyhow::Result<[u8; 65]> { + sign_hash_legacy_v(scalar, digest) +} + +/// Sign an EIP-191 `personal_sign` message. The message bytes are hashed with the standard +/// `"Ethereum Signed Message"` prefix by alloy before signing. +pub fn sign_personal_message(scalar: &[u8], message: &[u8]) -> anyhow::Result<[u8; 65]> { + let signer = PrivateKeySigner::from_slice(scalar) + .map_err(|e| anyhow::anyhow!("reconstruct signer: {e}"))?; + let sig = signer + .sign_message_sync(message) + .map_err(|e| anyhow::anyhow!("sign personal message: {e}"))?; + signature_bytes_legacy_v(&sig) +} + +/// Sign an already-computed EIP-712 digest. The caller is responsible for producing the digest +/// from reviewed typed data; this function does no JSON parsing and no network I/O. +pub fn sign_message_digest(scalar: &[u8], digest: B256) -> anyhow::Result<[u8; 65]> { + sign_hash_legacy_v(scalar, digest) +} + +fn sign_hash_legacy_v(scalar: &[u8], digest: B256) -> anyhow::Result<[u8; 65]> { let signer = PrivateKeySigner::from_slice(scalar) .map_err(|e| anyhow::anyhow!("reconstruct signer: {e}"))?; let sig = signer .sign_hash_sync(&digest) .map_err(|e| anyhow::anyhow!("sign digest: {e}"))?; + signature_bytes_legacy_v(&sig) +} + +fn signature_bytes_legacy_v(sig: &alloy_primitives::Signature) -> anyhow::Result<[u8; 65]> { let mut out = [0u8; 65]; let (r, rest) = out.split_at_mut(32); let (s, v) = rest.split_at_mut(32); diff --git a/crates/deckard-signerd/tests/message_signing.rs b/crates/deckard-signerd/tests/message_signing.rs new file mode 100644 index 0000000..ccf22f3 --- /dev/null +++ b/crates/deckard-signerd/tests/message_signing.rs @@ -0,0 +1,219 @@ +//! Message-signing daemon tests for issue #46. +//! +//! These drive the real daemon over the socket but do not touch a chain: message signing is +//! off-chain, human-approved, and never broadcast. + +mod common; + +use alloy_primitives::{Address, Bytes, B256}; +use deckard_contract::{ + ApprovalStatus, Decision, MessageSigningRisk, PendingPayloadView, ProposalOrigin, SignMessage, + SignMessageKind, SignMessageResult, SignerRequest, SignerResponse, TypedDataReview, +}; +use deckard_signerd::SignerClient; + +use common::*; + +const CHAIN: u64 = 31337; +const DUMMY_RPC: &str = "http://127.0.0.1:1"; + +fn personal_message() -> SignMessage { + SignMessage { + chain_id: CHAIN, + origin: "https://example.test".into(), + kind: SignMessageKind::PersonalSign { + message: Bytes::from_static(b"Sign in to Deckard"), + }, + } +} + +fn typed_message(domain_chain_id: u64) -> SignMessage { + SignMessage { + chain_id: CHAIN, + origin: "https://example.test".into(), + kind: SignMessageKind::TypedDataV4(TypedDataReview { + domain_name: Some("Permit2".into()), + domain_version: Some("1".into()), + domain_chain_id: Some(domain_chain_id), + verifying_contract: Some(Address::repeat_byte(0x22)), + primary_type: "PermitSingle".into(), + digest: B256::repeat_byte(0x42), + risks: vec![MessageSigningRisk::PermitLike], + }), + } +} + +fn eth_sign_message() -> SignMessage { + SignMessage { + chain_id: CHAIN, + origin: "https://example.test".into(), + kind: SignMessageKind::EthSign { + digest: B256::repeat_byte(0x33), + }, + } +} + +fn delegation_message() -> SignMessage { + SignMessage { + chain_id: CHAIN, + origin: "https://example.test".into(), + kind: SignMessageKind::Authorization7702 { + delegate: Address::repeat_byte(0x44), + nonce: 7, + }, + } +} + +fn needs_id(decision: Decision) -> deckard_contract::RequestId { + match decision { + Decision::NeedsApproval { request_id } => request_id, + other => panic!("expected NeedsApproval, got {other:?}"), + } +} + +#[tokio::test] +async fn personal_sign_requires_approval_then_signs_once() { + let dir = TempDir::new("message-personal"); + let (_wallet, _recipient) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, CHAIN, &[]); + let client = SignerClient::new(d.socket_path.clone()); + + assert_eq!( + client + .propose_message(&personal_message(), ProposalOrigin::App) + .await + .unwrap(), + Decision::Deny { + reason: "locked".into() + } + ); + + client.unlock(PASS).await.unwrap(); + let id = needs_id( + client + .propose_message(&personal_message(), ProposalOrigin::App) + .await + .unwrap(), + ); + assert_eq!( + id, + SignerClient::request_id_for_message(&personal_message()) + ); + + let pending = client.pending_list().await.unwrap(); + let rec = pending + .iter() + .find(|r| r.request_id == id) + .expect("message proposal appears in pending list"); + assert_eq!(rec.status, ApprovalStatus::Pending); + assert!(matches!(rec.payload, PendingPayloadView::Message(_))); + + assert_eq!( + client.sign_message(id).await.unwrap(), + SignMessageResult::Denied { + reason: "not_approved".into() + } + ); + + d.resolve(id, true); + match client.sign_message(id).await.unwrap() { + SignMessageResult::Signed { signature } => assert_eq!(signature.len(), 65), + other => panic!("expected signed message, got {other:?}"), + } + assert_eq!( + client.sign_message(id).await.unwrap(), + SignMessageResult::Denied { + reason: "already_signed".into() + } + ); +} + +#[tokio::test] +async fn typed_data_signs_digest_after_approval() { + let dir = TempDir::new("message-typed"); + let (_wallet, _recipient) = 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 = needs_id( + client + .propose_message(&typed_message(CHAIN), ProposalOrigin::App) + .await + .unwrap(), + ); + d.resolve(id, true); + match client.sign_message(id).await.unwrap() { + SignMessageResult::Signed { signature } => assert_eq!(signature.len(), 65), + other => panic!("expected signed typed data, got {other:?}"), + } +} + +#[tokio::test] +async fn unsafe_message_shapes_are_refused_before_pending() { + let dir = TempDir::new("message-refuse"); + let (_wallet, _recipient) = 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 + .propose_message(&typed_message(1), ProposalOrigin::App) + .await + .unwrap(), + Decision::Deny { + reason: "chainid_mismatch".into() + } + ); + assert_eq!( + client + .propose_message(ð_sign_message(), ProposalOrigin::App) + .await + .unwrap(), + Decision::Deny { + reason: "eth_sign_refused".into() + } + ); + assert_eq!( + client + .propose_message(&delegation_message(), ProposalOrigin::App) + .await + .unwrap(), + Decision::Deny { + reason: "delegation_refused".into() + } + ); + assert!(client.pending_list().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn stop_revokes_approved_but_unsigned_message() { + let dir = TempDir::new("message-stop"); + let (_wallet, _recipient) = 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 = needs_id( + client + .propose_message(&personal_message(), ProposalOrigin::Agent) + .await + .unwrap(), + ); + d.resolve(id, true); + match client + .request(&SignerRequest::RevokeAll) + .await + .expect("revoke all") + { + SignerResponse::Ack => {} + other => panic!("expected Ack, got {other:?}"), + } + assert_eq!( + client.sign_message(id).await.unwrap(), + SignMessageResult::Denied { + reason: "revoked".into() + } + ); +} diff --git a/docs/build/31-agent-quickstart.md b/docs/build/31-agent-quickstart.md index ae2d9b9..c1302cd 100644 --- a/docs/build/31-agent-quickstart.md +++ b/docs/build/31-agent-quickstart.md @@ -191,7 +191,12 @@ error is to retry — for two of these (marked **do NOT retry**) that instinct i | `receiver_zero` | The swap order receiver is the zero address. | Re-run the swap flow; a recurring case is a client bug. | | `zero_amount` | The swap order's sell amount is zero. | Re-quote with a non-zero sell amount. | | `valid_to_too_far` | The swap order's `valid_to` is more than 24h out. | Re-quote with a `valid_to` inside 24 hours. | +| `chainid_mismatch` | A typed-data message names a different domain chain than the active wallet chain. | Refuse; ask the dapp/user to switch to the right chain and re-create the signing request. | +| `eth_sign_refused` | Raw hash signing (`eth_sign`) was requested; it is too ambiguous to clear-sign safely. | Do not retry with `eth_sign`; use `personal_sign` or reviewed EIP-712 typed data instead. | +| `delegation_refused` | An EIP-7702 wallet-delegation authorization was requested, but Deckard has no reviewed allowlist flow yet. | Refuse for now; wait for an explicit delegation-review flow. | | `not_an_order` | The `request_id` points at a transaction where an order was expected (or vice versa). | Use the id returned by the matching propose call. | +| `not_a_message` | The `request_id` points at a non-message payload where message signing was expected. | Use the id returned by the matching message-signing propose call. | +| `not_a_transaction` | The `request_id` points at a non-transaction payload where transaction execution was expected. | Use the id returned by the matching transaction propose call. | | `already_signed` | The swap order was already signed. | Don't re-sign; cancel via the swap-cancel flow if you need to abort. | | `approve_no_matching_order` | An `approve` arrived with no stored order matching its token + amount. | Propose the swap order first; the approve must match it exactly. | | `approve_with_value` | A swap `approve` carried ETH value (would move ETH invisibly). | Re-issue a value-0 approve (the swap flow does this). | diff --git a/execplans/issue-46-message-signing-intents.md b/execplans/issue-46-message-signing-intents.md new file mode 100644 index 0000000..5d75bd1 --- /dev/null +++ b/execplans/issue-46-message-signing-intents.md @@ -0,0 +1,108 @@ +# Issue #46 — Clear-signing v2 + message-signing intents + +## 1. Title + +Issue #46 — message-signing intents for `personal_sign` and EIP-712. + +## 2. Context + +Deckard currently signs and broadcasts native transactions and signs CoW swap orders after human approval, but it has no generic off-chain message-signing intent. Dapp-origin signatures are a high-risk drainer surface, so message signing must be represented as a first-class reviewed payload before any EIP-1193 bridge methods expose it. + +This is security/product work across the frozen wire contract, signer daemon, shared clients, and approval rendering. + +## 3. Source Of Truth + +- User instruction: start issue #46 now. +- GitHub issue: https://github.com/hellno/deckard/issues/46 +- Repo guidance: `AGENTS.md`, `PLANS.md` +- Design guidance: `DESIGN.md` clear-signing review pattern +- ADRs/docs: `docs/adr/0001-dapp-connectivity-architecture.md`, `docs/adr/0005-erc-7730-clear-signing.md`, `docs/WALLETBEAT-COMPATIBILITY.md` +- Relevant code: `crates/deckard-contract/src/rpc.rs`, `policy.rs`, `deny_reasons.rs`; `crates/deckard-signerd/src/daemon.rs`, `signing.rs`, `request_id.rs`; `crates/deckard-app/src/activity_view.rs` +- Standards: EIP-191, EIP-712, ERC-7730, EIP-7702 + +## 4. Current State Analysis + +- `PendingPayloadView` supports only transaction, order, and shaped approve payloads. +- `deckard-signerd` can sign CoW EIP-712 digests only through `SignOrder` after approval. +- There is no wire type for personal messages or arbitrary typed-data review. +- The browser bridge intentionally supports only account/chain methods today. +- ERC-7730 normalization exists from #65, but it is not wired into message-signing flows. + +## 5. Target State + +- Add a first-class `SignMessage` payload with safe review metadata. +- Add pure `evaluate_message` policy logic: messages never auto-allow; revoked denies; chain mismatch denies; raw `eth_sign` denies; EIP-7702 delegation denies by default. +- Add wire RPCs to propose and sign a stored, approved message. +- Add deterministic message request ids that cannot collide with transaction or order ids. +- Add pending/activity views so the app can show message-signing requests without treating them as broadcasts. +- Keep browser bridge exposure out of this PR unless the reviewed internal path is already complete. + +## 6. Security And Trust Invariants + +- Private keys, seed phrases, passphrases, raw scalar bytes, and signatures are never logged. +- Message bytes may be user-visible but must not appear in denial reason strings or daemon logs. +- Messages are never auto-approved in v1. +- `eth_sign` raw-hash signing is refused. +- EIP-7702 delegation authorization is refused by default until a dedicated reviewed allow path exists. +- Typed-data chain id must match the active signer chain before approval/signing. +- Signing is offline and does not broadcast. + +## 7. UX And Design Constraints + +- Message-signing approval rows must use plain language and danger/caution lines. +- `personal_sign` must show decoded UTF-8 where possible, otherwise a byte-length/hash-style summary. +- EIP-712 typed data must show domain, verifying contract, primary type, digest, and warnings. +- Unknown/untrusted origin is shown as unverified. +- Full screenshot proof is deferred if no new app-visible route exercises the card in automation; tests must cover summary/render helpers. + +## 8. Execution Plan + +1. Write failing tests for contract message types, policy decisions, and id separation. +2. Implement `message_signing` wire types and policy evaluation. +3. Add signer-daemon propose/sign handlers with stored message payloads. +4. Add client helpers for message propose/sign. +5. Extend pending/activity payload rendering for message requests. +6. Update docs/threat model for refusal/fallback behavior. +7. Run Deckard DoD and open a PR. + +## 9. Validation Criteria + +Default Deckard Definition of Done: + +```text +cargo fmt --all --check +just check +cargo test --workspace +``` + +Task-specific checks: + +- `cargo test -p deckard-contract message_signing` +- `cargo test -p deckard-signerd message` +- `cargo test -p deckard-signerd request_id` + +## 10. Failure Signals + +- A message proposal returns `Decision::Allow`. +- Raw `eth_sign` or EIP-7702 authorization can reach signing. +- Typed data with mismatched chain id reaches approval/signing. +- Message bytes appear in deny reasons/log strings. +- A message payload is executable/broadcast through `Execute`. + +## 11. Risks And Tradeoffs + +- Generic EIP-712 JSON parsing is intentionally staged: this PR can define the reviewed typed-data/digest model, but bridge-side raw JSON parsing may need a later PR. +- UI integration is limited to existing pending/activity rendering unless the current app has a direct message-signing compose surface. +- Adding message wire variants is an additive protocol change; older clients will ignore unsupported variants. + +## 12. Out Of Scope + +- Browser bridge `personal_sign` / `eth_signTypedData_v4` exposure. +- `eth_sendTransaction`. +- EIP-5792 batch calls. +- ERC-7730 registry fetching/caching. +- Supporting raw `eth_sign`. + +## 13. Status Notes + +- 2026-06-23: Created plan after PR #132 merged and branch `feature/issue-46-message-signing-intents` was created from updated `main`.