Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 138 additions & 1 deletion crates/deckard-app/src/activity_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<Vec<_>>().join(" ");
if collapsed.chars().count() > 80 {
format!("{}…", collapsed.chars().take(80).collect::<String>())
} else {
collapsed
}
}
Err(_) => format!("{} bytes (not UTF-8)", bytes.len()),
}
}

Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions crates/deckard-contract/src/deny_reasons.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
52 changes: 49 additions & 3 deletions crates/deckard-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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),
});
Expand Down Expand Up @@ -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),
Expand All @@ -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,
},
]));
}

Expand Down Expand Up @@ -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),
Expand Down
71 changes: 71 additions & 0 deletions crates/deckard-contract/src/message_signing.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub domain_version: Option<String>,
pub domain_chain_id: Option<u64>,
pub verifying_contract: Option<Address>,
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<MessageSigningRisk>,
}

/// 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<B256> {
match &self.kind {
SignMessageKind::PersonalSign { .. } => None,
SignMessageKind::TypedDataV4(review) => Some(review.digest),
SignMessageKind::EthSign { digest } => Some(*digest),
SignMessageKind::Authorization7702 { .. } => None,
}
}
}
Loading
Loading