diff --git a/Cargo.lock b/Cargo.lock index 1a2ab1c..9b584df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1924,7 +1924,7 @@ checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "beam-cli" -version = "0.2.4" +version = "0.3.0" dependencies = [ "argon2", "async-trait", @@ -1941,6 +1941,7 @@ dependencies = [ "hex", "insta", "json-store", + "libc", "mockito", "num-bigint", "payy-evm-client", diff --git a/Cargo.toml b/Cargo.toml index 295cd61..4764665 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ time-interface = { path = "./pkg/time-interface" } time-system = { path = "./pkg/time-system" } email-interface = { path = "./pkg/email-interface" } email-memory = { path = "./pkg/email-memory" } +email-postmark = { path = "./pkg/email-postmark" } evm-json-rpc-interface = { path = "./pkg/evm-json-rpc-interface" } evm-json-rpc-reqwest = { path = "./pkg/evm-json-rpc-reqwest" } serde_yaml = { path = "./pkg/serde_yaml" } @@ -199,6 +200,8 @@ noir-abi-inputs-macro = { path = "./pkg/noir-abi-inputs-macro" } bungee-interface = { path = "./pkg/bungee-interface" } bungee-client-http = { path = "./pkg/bungee-client-http" } bungee = { path = "./pkg/bungee" } +postmark-interface = { path = "./pkg/postmark-interface" } +postmark-client-http = { path = "./pkg/postmark-client-http" } slack-client-interface = { path = "./pkg/slack-client-interface" } slack-client-http = { path = "./pkg/slack-client-http" } swap-pricer-price-cache = { path = "./pkg/swap-pricer-price-cache" } @@ -340,6 +343,7 @@ insta = { version = "1", features = ["json"] } itertools = "0.14.0" jsonwebtoken = { version = "10", features = ["rust_crypto"] } lazy_static = "1.5" +libc = "0.2" libp2p = { version = "0.51", default-features = false, features = [ "ping", "request-response", diff --git a/docker/docker-compose.payy-auth-phala.yml b/docker/docker-compose.payy-auth-phala.yml index 8e6b8a3..c7107ea 100644 --- a/docker/docker-compose.payy-auth-phala.yml +++ b/docker/docker-compose.payy-auth-phala.yml @@ -29,6 +29,15 @@ services: # allowlisted Origin header (curl smokes included). The hostname is the # dstack gateway name for this CVM: -.. PAYY_AUTH_PUBLIC_ORIGIN: https://3964805fd025d1d17e4bf900849d6eadf892ab0f-8787.dstack-pha-prod9.phala.network + # Confirmed Postmark sender signature. This address is non-secret and is + # safe in the attested compose text. + PAYY_AUTH_POSTMARK_FROM: auth@polybase.xyz + # The server token is a secret: it is configured as an encrypted dstack env + # var in the Phala dashboard (like the DSTACK_DOCKER_* pull credentials) and + # referenced here by name, so the value never appears in this attested + # compose text. Required whenever PAYY_AUTH_POSTMARK_FROM is set, or the bin + # fails closed at startup. + PAYY_AUTH_POSTMARK_SERVER_TOKEN: ${PAYY_AUTH_POSTMARK_SERVER_TOKEN} # EVM eth_sendTransaction broadcast endpoints, one entry per chain the # deployment can send on. The Alchemy URLs embed the same kind of # publicly-shipped API key the app bundle already ships to browsers, so @@ -46,10 +55,10 @@ services: {"app_id": "app-local", "caip2": "eip155:10", "endpoint_id": "optimism-mainnet", "base_url": "https://opt-mainnet.g.alchemy.com/v2/tM35lzH9DscymagwCy2GQ"}, {"app_id": "app-local", "caip2": "eip155:43114", "endpoint_id": "avalanche-c-chain", "base_url": "https://avax-mainnet.g.alchemy.com/v2/tM35lzH9DscymagwCy2GQ"} ] - # Smoke-box only: re-exposes plaintext login codes at - # GET /_local/passwordless/emails, which the public profile hides by - # default. Remove as soon as real email delivery lands or this CVM - # serves anyone but us. + # Smoke-box only: the dev inbox intentionally coexists with real Postmark + # delivery via the recording transport, so smokes can still read + # GET /_local/passwordless/emails. It exposes plaintext login codes; keep + # it off anywhere this CVM serves anyone but us. PAYY_AUTH_DEV_INBOX: "1" restart: unless-stopped diff --git a/pkg/beam-cli/Cargo.toml b/pkg/beam-cli/Cargo.toml index 0ed61da..4916dec 100644 --- a/pkg/beam-cli/Cargo.toml +++ b/pkg/beam-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "beam-cli" -version = "0.2.4" +version = "0.3.0" edition = "2024" publish = false @@ -23,6 +23,7 @@ eth-util = { workspace = true } futures = { workspace = true } hex = { workspace = true } json-store = { workspace = true } +libc = { workspace = true } num-bigint = { workspace = true } payy-evm-client = { workspace = true } rand = { workspace = true } diff --git a/pkg/beam-cli/README.md b/pkg/beam-cli/README.md index cdcbbfc..40d4bfc 100644 --- a/pkg/beam-cli/README.md +++ b/pkg/beam-cli/README.md @@ -330,6 +330,107 @@ path; confirmed receipts include the reported transaction status. is preparing a continuation. Removing an app keeps app-local data by default; pass `--purge-data` to delete `~/.beam/apps/data/` as well. +## Profiles + +Beam profiles delegate bounded public signing authority to an agent without +giving that agent the wallet password or raw private key. A profile is bound to +one stored wallet, has explicit command/app grants, and can be unlocked into a +short-lived local daemon session. The daemon holds the decrypted key only in +memory and checks profile policy immediately before signing the final +transaction. + +Profile lifecycle commands: + +```bash +beam profiles create agent --from alice +beam profiles list +beam profiles show agent +beam profiles revoke agent +beam profiles ledger agent +beam profiles remove agent +``` + +Creating, modifying, removing, and unlocking a profile prompts for the wallet +password. Profiles require a non-empty wallet password so profile integrity +cannot be keyed from an empty secret. Use `beam wallets change-password [wallet]` +to set a password on an existing empty-password wallet before creating a +profile. + +Grant direct public signing commands: + +```bash +beam profiles grant agent command native-transfer \ + --chain base \ + --recipient 0x1111111111111111111111111111111111111111 \ + --max-native 10000000000000000 \ + --max-gas 50000 \ + --budget 100000000000000000 + +beam profiles grant agent command erc20-transfer \ + --chain base \ + --token 0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 \ + --recipient 0x1111111111111111111111111111111111111111 \ + --max-token 1000000 \ + --budget 10000000 + +beam profiles grant agent command erc20-approval \ + --chain base \ + --token 0x833589fcd6edb6e08f4c7c32d4f71b54bda02913 \ + --spender 0x2222222222222222222222222222222222222222 \ + --max-token 1000000 + +beam profiles grant agent command contract-transaction \ + --chain base \ + --target 0x3333333333333333333333333333333333333333 \ + --selector 0xa9059cbb \ + --max-native 0 +``` + +Amounts and budgets are base-unit integers. Durations use seconds by default +and also accept `s`, `m`, `h`, and `d` suffixes. + +Grant app action plans from an existing approval continuation or a saved action +plan JSON file: + +```bash +beam --chain base --from alice x uniswap swap USDC ETH 100 --prepare --format json +beam profiles grant agent app uniswap --approval-id --budget 10000000 +beam profiles grant agent app uniswap --plan-json ./plan.json +``` + +App profile grants bind the app identity, active artifact digests, command, +wallet, chain, action-plan hash, bindings, constraints, step metadata, and plan +expiry. An app update or changed action plan requires a new grant. + +Unlock a profile for agent use: + +```bash +beam profiles unlock agent --ttl 1h --print-env +export BEAM_PROFILE='agent' +export BEAM_PROFILE_TOKEN='...' +``` + +Subsequent commands can select the profile with `--profile`: + +```bash +beam --profile agent --chain base transfer 0x1111111111111111111111111111111111111111 0.001 +beam --profile agent --chain base erc20 transfer USDC 0x1111111111111111111111111111111111111111 1 +beam --profile agent --chain base x uniswap swap USDC ETH 100 --no-prompt +beam profiles sessions +beam profiles lock agent +``` + +If a selected profile is missing, expired, locked, or does not match the final +transaction, Beam fails closed instead of prompting for the password. Wallet, +profile, chain/RPC, token, app install/update/remove, and privacy management +commands are not authorized by profile sessions. + +Profiles v1 cover public EVM signing only: native transfers, ERC20 transfers, +ERC20 approvals, public contract transactions, public fetch payments, and +public Beam app action plans. Privacy actions continue to prompt or fail closed +under profiles v1. Future privacy profile capabilities must keep privacy key +derivation, note access, proof generation, and spend authorization inside Beam. + ## Privacy Beam privacy support is configured per chain. Built-in Payy privacy-capable chains include a @@ -381,6 +482,7 @@ beam wallets export-private-key [wallet] beam wallets export-recovery-phrase [wallet] beam wallets import-recovery-phrase [--name ] [--expected-address
] [--phrase-stdin | --phrase-fd ] beam wallets list +beam wallets change-password [wallet] beam wallets rename beam wallets address [--private-key-stdin | --private-key-fd ] beam wallets use @@ -418,6 +520,9 @@ Notes: - `beam wallets create` prompts for a wallet name when you omit `[name]`, suggesting the next available `wallet-N` alias and accepting it when you press Enter. - `beam wallets import` uses a verified ENS reverse record as the default wallet name when one resolves back to the imported address; otherwise it falls back to the next `wallet-N` alias. - The CLI prompts for a password when creating/importing a wallet. Press Enter at the password prompt to create a wallet with no password; whitespace-only passwords are rejected. +- `beam wallets change-password [wallet]` re-encrypts the selected wallet after prompting for the + current password and a new password. Press Enter at the current password prompt for wallets that + were created with no password. - Beam trims surrounding whitespace and sanitizes terminal control characters in wallet names, rejecting aliases that become empty after normalization. - Commands that need signing prompt for the keystore password again before decrypting. - `beam privacy address` uses the same password prompt and keystore integrity checks before @@ -439,6 +544,7 @@ beam --format compact wallets export-private-key alice beam wallets import --private-key-fd 3 --name alice 3< ~/.config/beam/private-key.txt beam wallets address --private-key-fd 3 3< ~/.config/beam/private-key.txt beam wallets export-recovery-phrase alice +beam wallets change-password alice beam wallets import-recovery-phrase --name alice beam wallets import-recovery-phrase --expected-address 0x1111111111111111111111111111111111111111 --name alice pass show beam/alice/recovery-phrase | beam wallets import-recovery-phrase --phrase-stdin --name alice diff --git a/pkg/beam-cli/src/cli.rs b/pkg/beam-cli/src/cli.rs index 3136cc0..22beb8f 100644 --- a/pkg/beam-cli/src/cli.rs +++ b/pkg/beam-cli/src/cli.rs @@ -6,6 +6,7 @@ mod fetch; mod gas; mod normalize; mod privacy; +mod profiles; mod wallet; pub mod util; @@ -21,6 +22,7 @@ pub use fetch::FetchArgs; pub use gas::*; pub(crate) use normalize::normalize_cli_args; pub use privacy::*; +pub use profiles::*; use util::UtilAction; pub use wallet::*; @@ -39,6 +41,9 @@ pub struct Cli { #[arg(long, global = true)] pub chain: Option, + #[arg(long, global = true)] + pub profile: Option, + #[arg(long = "format", global = true, value_enum, default_value_t = OutputMode::Default)] pub output: OutputMode, @@ -89,6 +94,11 @@ pub enum Command { #[command(subcommand)] action: AppsAction, }, + /// Manage delegated signing profiles + Profiles { + #[command(subcommand)] + action: ProfilesAction, + }, /// Run a Beam app X(AppRunArgs), /// Work with private balances and transfers @@ -129,6 +139,8 @@ pub enum Command { Fetch(FetchArgs), /// Check for beam updates Update, + #[command(name = "__profile-daemon", hide = true)] + ProfileDaemon(ProfileDaemonArgs), #[command(name = "__refresh-update-status", hide = true)] RefreshUpdateStatus, } @@ -231,6 +243,7 @@ impl Cli { InvocationOverrides { chain: self.chain.clone(), from: self.from.clone(), + profile: self.profile.clone(), rpc: self.rpc.clone(), } } diff --git a/pkg/beam-cli/src/cli/profiles.rs b/pkg/beam-cli/src/cli/profiles.rs new file mode 100644 index 0000000..4984722 --- /dev/null +++ b/pkg/beam-cli/src/cli/profiles.rs @@ -0,0 +1,150 @@ +use std::path::PathBuf; + +use clap::{Args, Subcommand, ValueEnum}; + +#[derive(Debug, Subcommand)] +pub enum ProfilesAction { + /// Create a delegated signing profile + Create(ProfileCreateArgs), + /// List delegated signing profiles + List, + /// Show one delegated signing profile + Show { profile: String }, + /// Add a command or app grant to a profile + Grant(Box), + /// Revoke one grant from a profile + Revoke(ProfileRevokeArgs), + /// Remove a delegated signing profile + Remove { profile: String }, + /// Show profile usage ledger entries + Ledger { profile: String }, + /// Unlock a profile session in the local profile daemon + Unlock(ProfileUnlockArgs), + /// Lock an unlocked profile session + Lock { profile: String }, + /// List unlocked profile sessions + Sessions, +} + +#[derive(Clone, Debug, Args)] +pub struct ProfileCreateArgs { + pub profile: String, + #[arg(long)] + pub from: String, +} + +#[derive(Clone, Debug, Args)] +pub struct ProfileGrantArgs { + pub profile: String, + #[command(subcommand)] + pub grant: ProfileGrantKind, +} + +#[derive(Clone, Debug, Subcommand)] +pub enum ProfileGrantKind { + /// Grant a direct public signing command + Command(ProfileCommandGrantArgs), + /// Grant a Beam app action plan + App(ProfileAppGrantArgs), +} + +#[derive(Clone, Debug, Args)] +pub struct ProfileCommandGrantArgs { + #[arg(value_enum)] + pub command: ProfileCommandKind, + #[arg(long)] + pub chain: Option, + #[arg(long)] + pub token: Option, + #[arg(long)] + pub recipient: Option, + #[arg(long)] + pub target: Option, + #[arg(long)] + pub selector: Option, + #[arg(long)] + pub spender: Option, + #[arg(long)] + pub max_native: Option, + #[arg(long)] + pub max_token: Option, + #[arg(long)] + pub max_gas: Option, + #[arg(long)] + pub budget: Option, + #[arg(long)] + pub ttl: Option, + #[arg(long, default_value_t = false)] + pub allow_unlimited_approval: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub enum ProfileCommandKind { + NativeTransfer, + Erc20Transfer, + Erc20Approval, + ContractTransaction, + FetchPayment, +} + +#[derive(Clone, Debug, Args)] +pub struct ProfileAppGrantArgs { + pub app: String, + #[arg(long)] + pub command: Option, + #[arg(long)] + pub approval_id: Option, + #[arg(long)] + pub plan_json: Option, + #[arg(long)] + pub registry_url: Option, + #[arg(long)] + pub version: Option, + #[arg(long)] + pub manifest_digest: Option, + #[arg(long)] + pub module_digest: Option, + #[arg(long)] + pub chain: Option, + #[arg(long)] + pub wallet: Option, + #[arg(long)] + pub plan_hash: Option, + #[arg(long)] + pub max_gas: Option, + #[arg(long)] + pub budget: Option, + #[arg(long)] + pub ttl: Option, + #[arg(long, default_value_t = false)] + pub allow_unlimited_approval: bool, +} + +#[derive(Clone, Debug, Args)] +pub struct ProfileRevokeArgs { + pub profile: String, + pub grant_id: String, +} + +#[derive(Clone, Debug, Args)] +pub struct ProfileUnlockArgs { + pub profile: String, + #[arg(long)] + pub ttl: Option, + #[arg(long, default_value_t = false)] + pub print_env: bool, +} + +#[derive(Clone, Debug, Args)] +pub struct ProfileDaemonArgs { + #[arg(long)] + pub root: PathBuf, + #[arg(long)] + pub profile: String, + #[arg(long)] + pub socket: PathBuf, + #[arg(long)] + pub session: String, + #[arg(long)] + pub expires_at: u64, +} diff --git a/pkg/beam-cli/src/cli/wallet.rs b/pkg/beam-cli/src/cli/wallet.rs index 19c6288..6f0f80d 100644 --- a/pkg/beam-cli/src/cli/wallet.rs +++ b/pkg/beam-cli/src/cli/wallet.rs @@ -26,6 +26,8 @@ pub enum WalletAction { }, /// List stored wallets List, + /// Change a stored wallet's encryption password + ChangePassword { wallet: Option }, /// Rename a stored wallet Rename { name: String, new_name: String }, /// Derive an address from a private key diff --git a/pkg/beam-cli/src/commands/apps/execution.rs b/pkg/beam-cli/src/commands/apps/execution.rs index bb30297..860848f 100644 --- a/pkg/beam-cli/src/commands/apps/execution.rs +++ b/pkg/beam-cli/src/commands/apps/execution.rs @@ -1,14 +1,16 @@ // lint-long-file-override allow-max-lines=300 -use contextful::ResultContextExt; +mod tx_metadata; + use serde_json::{Value, json}; use crate::{ apps::{ Error as AppError, + approvals::plan_hash, model::{ActionPlan, ActionStep, ApprovalFeeCap}, }, - commands::signing::prompt_active_signer, - error::{Error, Result}, + commands::signing::{active_signer_for_intent, active_signing_address}, + error::Result, evm::{ CalldataTransaction, TransactionGasPolicy, erc20_allowance, send_calldata_with_fee_report, transaction_fee_json, @@ -17,21 +19,38 @@ use crate::{ CommandOutput, confirmed_transaction_message, dropped_transaction_message, pending_transaction_message, with_loading_handle, }, + profiles::model::{ActionGrantBinding, ActionGrantStep, AppActionIntent, PublicSigningIntent}, runtime::{BeamApp, parse_address}, signer::Signer, transaction::{TransactionExecution, loading_message}, }; +use self::tx_metadata::{TransactionValue, parse_hex_data, parse_u256, transaction}; + pub async fn execute_plan( app: &BeamApp, plan: &ActionPlan, fee_caps: &[ApprovalFeeCap], ) -> Result { - let signer = prompt_active_signer(app).await?; - execute_plan_with_signer(app, plan, fee_caps, &signer).await + execute_plan_with_approval(app, plan, fee_caps, None).await +} + +pub async fn execute_plan_with_approval( + app: &BeamApp, + plan: &ActionPlan, + fee_caps: &[ApprovalFeeCap], + approval_id: Option, +) -> Result { + let wallet = active_signing_address(app).await?; + let signer = active_signer_for_intent( + app, + PublicSigningIntent::AppActionPlan(app_intent(plan, format!("{wallet:#x}"), approval_id)?), + ) + .await?; + execute_plan_with_signer(app, plan, fee_caps, signer.as_ref()).await } -pub(crate) async fn execute_plan_with_signer( +pub(crate) async fn execute_plan_with_signer( app: &BeamApp, plan: &ActionPlan, fee_caps: &[ApprovalFeeCap], @@ -107,6 +126,47 @@ pub(crate) async fn execute_plan_with_signer( )) } +fn app_intent( + plan: &ActionPlan, + wallet: String, + approval_id: Option, +) -> Result { + Ok(AppActionIntent { + app_id: plan.app_id.clone(), + app_version: plan.app_version.clone(), + registry_url: None, + manifest_digest: plan.manifest_sha256.clone(), + module_digest: plan.wasm_sha256.clone(), + command: plan.command.clone(), + wallet, + chain: plan.chain.clone(), + plan_hash: plan_hash(plan)?, + expires_at: plan.expires_at, + bindings: plan + .bindings + .iter() + .map(|binding| ActionGrantBinding { + key: binding.key.clone(), + value: binding.value.clone(), + }) + .collect(), + constraints: plan.constraints.clone(), + steps: plan + .steps + .iter() + .map(|step| ActionGrantStep { + kind: step.kind.clone(), + target: step.target.clone(), + selector: step.selector.clone(), + spender: step.spender.clone(), + value: step.value.clone(), + metadata: step.metadata.clone(), + }) + .collect(), + approval_id, + }) +} + fn approval_step_ready(step: &ActionStep, execution: &TransactionExecution) -> bool { if step.kind != "erc20-approval" { return true; @@ -209,45 +269,6 @@ async fn should_skip_approval( Ok(allowance >= required) } -fn transaction(step: &ActionStep) -> Option> { - step.metadata - .get("transaction") - .and_then(Value::as_object) - .map(TransactionValue) -} - -struct TransactionValue<'a>(&'a serde_json::Map); - -impl TransactionValue<'_> { - fn data(&self) -> Result<&str> { - self.string("data") - } - - fn gas_limit(&self) -> Option<&str> { - self.optional_string("gas_limit") - } - - fn to(&self) -> Result<&str> { - self.string("to") - } - - fn value(&self) -> Option<&str> { - self.optional_string("value") - } - - fn string(&self, key: &str) -> Result<&str> { - self.optional_string(key).ok_or_else(|| { - Error::App(AppError::InvalidHostRequest { - reason: format!("transaction missing {key}"), - }) - }) - } - - fn optional_string(&self, key: &str) -> Option<&str> { - self.0.get(key).and_then(Value::as_str) - } -} - fn parse_gas_policy( step_index: usize, transaction: &TransactionValue<'_>, @@ -262,16 +283,3 @@ fn parse_gas_policy( max_network_fee: Some(parse_u256(&fee_cap.approved_max_total_fee_wei)?), })) } - -fn parse_hex_data(value: &str) -> Result> { - hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| Error::InvalidHexData { - value: value.to_string(), - }) -} - -fn parse_u256(value: &str) -> Result { - if let Some(value) = value.strip_prefix("0x") { - return Ok(contracts::U256::from_str_radix(value, 16).context("parse hex u256")?); - } - Ok(contracts::U256::from_dec_str(value).context("parse decimal u256")?) -} diff --git a/pkg/beam-cli/src/commands/apps/execution/tx_metadata.rs b/pkg/beam-cli/src/commands/apps/execution/tx_metadata.rs new file mode 100644 index 0000000..d9b7c2e --- /dev/null +++ b/pkg/beam-cli/src/commands/apps/execution/tx_metadata.rs @@ -0,0 +1,59 @@ +use contextful::ResultContextExt; +use serde_json::Value; + +use crate::{ + apps::{Error as AppError, model::ActionStep}, + error::{Error, Result}, +}; + +pub fn transaction(step: &ActionStep) -> Option> { + step.metadata + .get("transaction") + .and_then(Value::as_object) + .map(TransactionValue) +} + +pub struct TransactionValue<'a>(&'a serde_json::Map); + +impl TransactionValue<'_> { + pub fn data(&self) -> Result<&str> { + self.string("data") + } + + pub fn gas_limit(&self) -> Option<&str> { + self.optional_string("gas_limit") + } + + pub fn to(&self) -> Result<&str> { + self.string("to") + } + + pub fn value(&self) -> Option<&str> { + self.optional_string("value") + } + + fn string(&self, key: &str) -> Result<&str> { + self.optional_string(key).ok_or_else(|| { + Error::App(AppError::InvalidHostRequest { + reason: format!("transaction missing {key}"), + }) + }) + } + + fn optional_string(&self, key: &str) -> Option<&str> { + self.0.get(key).and_then(Value::as_str) + } +} + +pub fn parse_hex_data(value: &str) -> Result> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| Error::InvalidHexData { + value: value.to_string(), + }) +} + +pub fn parse_u256(value: &str) -> Result { + if let Some(value) = value.strip_prefix("0x") { + return Ok(contracts::U256::from_str_radix(value, 16).context("parse hex u256")?); + } + Ok(contracts::U256::from_dec_str(value).context("parse decimal u256")?) +} diff --git a/pkg/beam-cli/src/commands/apps/mod.rs b/pkg/beam-cli/src/commands/apps/mod.rs index ca8baa3..167feb1 100644 --- a/pkg/beam-cli/src/commands/apps/mod.rs +++ b/pkg/beam-cli/src/commands/apps/mod.rs @@ -1,4 +1,4 @@ -// lint-long-file-override allow-max-lines=400 +// lint-long-file-override allow-max-lines=500 mod args; mod execution; mod fee_caps; @@ -34,7 +34,7 @@ use crate::{ }; use args::{filtered_app_args, is_help_requested}; -use execution::execute_plan; +use execution::{execute_plan, execute_plan_with_approval}; use fee_caps::{approval_fee_caps, approval_fee_caps_for_execution, max_network_fee_arg}; use plans::{validate_guest_plan, validate_plan_permissions}; use prompt::approve_interactively; @@ -126,10 +126,12 @@ pub async fn run_app(app: &BeamApp, args: AppRunArgs) -> Result<()> { } if approval_required { - if no_prompt { + if no_prompt && app.overrides.profile.is_none() { return Err(AppError::ApprovalRequired.into()); } - approve_interactively(&render::render_plan_with_fee_caps(&plan, &fee_caps))?; + if !no_prompt { + approve_interactively(&render::render_plan_with_fee_caps(&plan, &fee_caps))?; + } } execute_plan(app, &plan, &fee_caps) .await? @@ -316,7 +318,13 @@ async fn approvals(app: &BeamApp, action: AppApprovalAction) -> Result<()> { let fee_caps = approval_fee_caps_for_execution(app, &approval, max_network_fee_wei.as_deref()) .await?; - let output = execute_plan(app, &approval.plan, &fee_caps).await?; + let output = execute_plan_with_approval( + app, + &approval.plan, + &fee_caps, + Some(approval_id.clone()), + ) + .await?; store.mark_executed(&approval_id).await?; return output.print(app.output_mode); } diff --git a/pkg/beam-cli/src/commands/call.rs b/pkg/beam-cli/src/commands/call.rs index 0ff5176..afebb79 100644 --- a/pkg/beam-cli/src/commands/call.rs +++ b/pkg/beam-cli/src/commands/call.rs @@ -1,17 +1,19 @@ +// lint-long-file-override allow-max-lines=300 use contracts::U256; use serde_json::json; use web3::ethabi::{Function, ParamType, StateMutability}; use crate::{ - abi::parse_function, + abi::{encode_input, parse_function}, cli::{CallArgs, SendArgs}, - commands::signing::prompt_active_signer, + commands::signing::{active_signer_for_intent, active_signing_address}, error::Result, evm::{FunctionCall, call_function, parse_units, send_function}, output::{ CommandOutput, confirmed_transaction_message, dropped_transaction_message, pending_transaction_message, with_loading, with_loading_handle, }, + profiles::model::{ContractIntent, PublicSigningIntent}, runtime::{BeamApp, parse_address}, transaction::{TransactionExecution, loading_message}, }; @@ -56,7 +58,20 @@ pub async fn run_write(app: &BeamApp, args: SendArgs) -> Result<()> { let contract = parse_address(&args.call.contract)?; let function = parse_function(&args.call.function_sig, StateMutability::NonPayable)?; let call_args = resolve_address_args(app, &function, &args.call.args).await?; - let signer = prompt_active_signer(app).await?; + let data = encode_input(&function, &call_args)?; + let selector = format!("0x{}", hex::encode(data.get(..4).unwrap_or(&[]))); + let wallet = active_signing_address(app).await?; + let signer = active_signer_for_intent( + app, + PublicSigningIntent::ContractTransaction(ContractIntent { + wallet: format!("{wallet:#x}"), + chain: chain.entry.key.clone(), + target: format!("{contract:#x}"), + selector, + native_value: value.to_string(), + }), + ) + .await?; let action = if value.is_zero() { format!("transaction to {contract:#x}") } else { @@ -68,7 +83,7 @@ pub async fn run_write(app: &BeamApp, args: SendArgs) -> Result<()> { |loading| async move { send_function( &client, - &signer, + signer.as_ref(), FunctionCall { args: &call_args, contract, diff --git a/pkg/beam-cli/src/commands/erc20.rs b/pkg/beam-cli/src/commands/erc20.rs index 7f2027f..3856a4a 100644 --- a/pkg/beam-cli/src/commands/erc20.rs +++ b/pkg/beam-cli/src/commands/erc20.rs @@ -1,22 +1,24 @@ // lint-long-file-override allow-max-lines=300 -use serde_json::{Value, json}; +mod token_output; + +use serde_json::json; use web3::ethabi::StateMutability; use crate::{ abi::parse_function, cli::Erc20Action, - commands::signing::prompt_active_signer, + commands::signing::{active_signer_for_intent, active_signing_address}, error::{Error, Result}, evm::{FunctionCall, erc20_balance, erc20_decimals, format_units, parse_units, send_function}, human_output::sanitize_control_chars, - output::{ - CommandOutput, OutputMode, confirmed_transaction_message, dropped_transaction_message, - pending_transaction_message, with_loading, with_loading_handle, - }, + output::{CommandOutput, with_loading, with_loading_handle}, + profiles::model::{ApprovalIntent, PublicSigningIntent, TokenTransferIntent}, runtime::BeamApp, - transaction::{TransactionExecution, loading_message}, + transaction::loading_message, }; +use self::token_output::{TokenWriteOutputConfig, print_token_write_output}; + pub async fn run(app: &BeamApp, action: Erc20Action) -> Result<()> { match action { Erc20Action::Balance { token, address } => balance(app, &token, address.as_deref()).await, @@ -110,7 +112,18 @@ async fn transfer(app: &BeamApp, token: &str, to: &str, amount: &str) -> Result< } }; let amount_value = parse_units(amount, usize::from(decimals))?; - let signer = prompt_active_signer(app).await?; + let wallet = active_signing_address(app).await?; + let signer = active_signer_for_intent( + app, + PublicSigningIntent::Erc20Transfer(TokenTransferIntent { + wallet: format!("{wallet:#x}"), + chain: chain.entry.key.clone(), + token: format!("{:#x}", token.address), + recipient: format!("{to:#x}"), + amount: amount_value.to_string(), + }), + ) + .await?; let function = parse_function("transfer(address,uint256)", StateMutability::NonPayable)?; let action = format!("transfer of {amount} {token_label} to {to:#x}"); let execution = with_loading_handle( @@ -119,7 +132,7 @@ async fn transfer(app: &BeamApp, token: &str, to: &str, amount: &str) -> Result< |loading| async move { send_function( &client, - &signer, + signer.as_ref(), FunctionCall { args: &[format!("{to:#x}"), amount_value.to_string()], contract: token.address, @@ -172,7 +185,18 @@ async fn approve(app: &BeamApp, token: &str, spender: &str, amount: &str) -> Res } }; let amount_value = parse_units(amount, usize::from(decimals))?; - let signer = prompt_active_signer(app).await?; + let wallet = active_signing_address(app).await?; + let signer = active_signer_for_intent( + app, + PublicSigningIntent::Erc20Approval(ApprovalIntent { + wallet: format!("{wallet:#x}"), + chain: chain.entry.key.clone(), + token: format!("{:#x}", token.address), + spender: format!("{spender:#x}"), + amount: amount_value.to_string(), + }), + ) + .await?; let function = parse_function("approve(address,uint256)", StateMutability::NonPayable)?; let action = format!("approval of {amount} {token_label} for {spender:#x}"); let execution = with_loading_handle( @@ -181,7 +205,7 @@ async fn approve(app: &BeamApp, token: &str, spender: &str, amount: &str) -> Res |loading| async move { send_function( &client, - &signer, + signer.as_ref(), FunctionCall { args: &[format!("{spender:#x}"), amount_value.to_string()], contract: token.address, @@ -218,76 +242,3 @@ async fn approve(app: &BeamApp, token: &str, spender: &str, amount: &str) -> Res }, ) } - -struct TokenWriteOutputConfig { - amount: String, - chain_key: String, - confirmed_summary: String, - dropped_summary: String, - pending_summary: String, - target_key: &'static str, - target_value: String, - token_address: String, - token_label: String, -} - -fn print_token_write_output( - output_mode: OutputMode, - execution: TransactionExecution, - config: TokenWriteOutputConfig, -) -> Result<()> { - let (default, state, block_number, status, tx_hash) = match execution { - TransactionExecution::Confirmed(outcome) => ( - confirmed_transaction_message( - config.confirmed_summary, - &outcome.tx_hash, - outcome.block_number, - ), - "confirmed", - outcome.block_number, - outcome.status, - outcome.tx_hash, - ), - TransactionExecution::Pending(pending) => ( - pending_transaction_message( - config.pending_summary, - &pending.tx_hash, - pending.block_number, - ), - "pending", - pending.block_number, - None, - pending.tx_hash, - ), - TransactionExecution::Dropped(dropped) => ( - dropped_transaction_message( - config.dropped_summary, - &dropped.tx_hash, - dropped.block_number, - ), - "dropped", - dropped.block_number, - None, - dropped.tx_hash, - ), - }; - - let mut value = json!({ - "amount": config.amount, - "block_number": block_number, - "chain": config.chain_key, - "state": state, - "status": status, - "token": config.token_label, - "token_address": config.token_address, - "tx_hash": tx_hash.clone(), - }); - value.as_object_mut().expect("token write output").insert( - config.target_key.to_string(), - Value::String(config.target_value), - ); - - CommandOutput::new(default, value) - .compact(tx_hash) - .print(output_mode) -} diff --git a/pkg/beam-cli/src/commands/erc20/token_output.rs b/pkg/beam-cli/src/commands/erc20/token_output.rs new file mode 100644 index 0000000..a6e4327 --- /dev/null +++ b/pkg/beam-cli/src/commands/erc20/token_output.rs @@ -0,0 +1,85 @@ +use serde_json::{Value, json}; + +use crate::{ + error::Result, + output::{ + CommandOutput, OutputMode, confirmed_transaction_message, dropped_transaction_message, + pending_transaction_message, + }, + transaction::TransactionExecution, +}; + +pub(super) struct TokenWriteOutputConfig { + pub amount: String, + pub chain_key: String, + pub confirmed_summary: String, + pub dropped_summary: String, + pub pending_summary: String, + pub target_key: &'static str, + pub target_value: String, + pub token_address: String, + pub token_label: String, +} + +pub(super) fn print_token_write_output( + output_mode: OutputMode, + execution: TransactionExecution, + config: TokenWriteOutputConfig, +) -> Result<()> { + let (default, state, block_number, status, tx_hash) = match execution { + TransactionExecution::Confirmed(outcome) => ( + confirmed_transaction_message( + config.confirmed_summary, + &outcome.tx_hash, + outcome.block_number, + ), + "confirmed", + outcome.block_number, + outcome.status, + outcome.tx_hash, + ), + TransactionExecution::Pending(pending) => ( + pending_transaction_message( + config.pending_summary, + &pending.tx_hash, + pending.block_number, + ), + "pending", + pending.block_number, + None, + pending.tx_hash, + ), + TransactionExecution::Dropped(dropped) => ( + dropped_transaction_message( + config.dropped_summary, + &dropped.tx_hash, + dropped.block_number, + ), + "dropped", + dropped.block_number, + None, + dropped.tx_hash, + ), + }; + + let mut value = json!({ + "amount": config.amount, + "block_number": block_number, + "chain": config.chain_key, + "state": state, + "status": status, + "token": config.token_label, + "token_address": config.token_address, + "tx_hash": tx_hash.clone(), + }); + if let Some(object) = value.as_object_mut() { + object.insert( + config.target_key.to_string(), + Value::String(config.target_value), + ); + } + + CommandOutput::new(default, value) + .compact(tx_hash) + .print(output_mode) +} diff --git a/pkg/beam-cli/src/commands/fetch/payment.rs b/pkg/beam-cli/src/commands/fetch/payment.rs index 45b83d2..285d5c4 100644 --- a/pkg/beam-cli/src/commands/fetch/payment.rs +++ b/pkg/beam-cli/src/commands/fetch/payment.rs @@ -1,35 +1,28 @@ // lint-long-file-override allow-max-lines=300 mod approval; mod chain_match; +mod execute; mod prepare; mod private; mod resolve; mod selection; use contracts::{Address, Client, U256}; -use serde_json::{Value, json}; -use web3::ethabi::StateMutability; +use serde_json::Value; use crate::{ - abi::parse_function, chains::BeamChains, - commands::signing::prompt_active_signer, error::{Error, Result}, - evm::{ - FunctionCall, TransactionGasPolicy, format_units, parse_units, send_function_with_gas, - send_native_with_gas, - }, + evm::{TransactionGasPolicy, format_units, parse_units}, human_output::sanitize_control_chars, - output::with_loading_handle, privacy_config::PrivacyProfile, - runtime::BeamApp, - transaction::{TransactionExecution, loading_message}, }; #[cfg(test)] pub(crate) use self::approval::approve_payment_with; pub(crate) use self::{ approval::approve_payment, + execute::execute_payment, prepare::{prepare_mpp_payment, prepare_x402_payment}, }; @@ -101,101 +94,6 @@ impl PaymentChain { } } -pub(crate) async fn execute_payment( - app: &BeamApp, - payment: &PreparedPayment, -) -> Result { - if payment.private_recipient.is_some() { - return private::execute_private_payment(app, payment).await; - } - - let signer = prompt_active_signer(app).await?; - let action = format!( - "payment of {} {} to {:#x}", - payment.amount_display, payment.asset.label, payment.recipient - ); - let client = payment.client.clone(); - let recipient = payment.recipient; - let amount = payment.amount; - let gas = payment.transaction_gas(); - - let execution = with_loading_handle( - app.output_mode, - format!("Sending {action} and waiting for confirmation..."), - |loading| async move { - match payment.asset.kind.clone() { - PaymentAssetKind::Native => { - send_native_with_gas( - &client, - &signer, - recipient, - amount, - Some(gas), - move |update| loading.set_message(loading_message(&action, &update)), - tokio::signal::ctrl_c(), - ) - .await - } - PaymentAssetKind::Erc20(token) => { - let function = - parse_function("transfer(address,uint256)", StateMutability::NonPayable)?; - let args = vec![format!("{recipient:#x}"), amount.to_string()]; - - send_function_with_gas( - &client, - &signer, - FunctionCall { - args: &args, - contract: token, - function: &function, - value: U256::zero(), - }, - Some(gas), - move |update| loading.set_message(loading_message(&action, &update)), - tokio::signal::ctrl_c(), - ) - .await - } - } - }, - ) - .await?; - - let tx_hash = match execution { - TransactionExecution::Confirmed(outcome) => outcome.tx_hash, - TransactionExecution::Pending(pending) => { - return Err(Error::FetchPaymentUnconfirmed { - tx_hash: pending.tx_hash, - }); - } - TransactionExecution::Dropped(dropped) => { - return Err(Error::FetchPaymentUnconfirmed { - tx_hash: dropped.tx_hash, - }); - } - }; - - Ok(ExecutedPayment { - accepted: payment.accepted.clone(), - network: payment.network.clone(), - proof: json!({ - "amount": payment.amount.to_string(), - "asset": payment.asset_id, - "chainId": payment.chain.chain_id, - "from": format!("{:#x}", payment.payer), - "kind": "beam-evm-transfer", - "network": payment.network, - "to": format!("{:#x}", payment.recipient), - "txHash": tx_hash, - }), - scheme: payment.scheme.clone(), - source: Some(format!( - "did:pkh:eip155:{}:{:#x}", - payment.chain.chain_id, payment.payer - )), - }) -} - impl PreparedPayment { pub(crate) fn ensure_max_fee_allows(&self, max_fee: &str) -> Result<()> { let gas_threshold = parse_units(max_fee, 18)?; @@ -226,7 +124,7 @@ impl PreparedPayment { Ok(()) } - fn transaction_gas(&self) -> TransactionGasPolicy { + pub(super) fn transaction_gas(&self) -> TransactionGasPolicy { TransactionGasPolicy { gas_limit: Some(self.gas.gas_limit), max_network_fee: Some(self.gas.fee), diff --git a/pkg/beam-cli/src/commands/fetch/payment/execute.rs b/pkg/beam-cli/src/commands/fetch/payment/execute.rs new file mode 100644 index 0000000..e3db06a --- /dev/null +++ b/pkg/beam-cli/src/commands/fetch/payment/execute.rs @@ -0,0 +1,138 @@ +use contracts::U256; +use serde_json::json; +use web3::ethabi::StateMutability; + +use crate::{ + abi::parse_function, + commands::{ + fetch::payment::{ExecutedPayment, PaymentAssetKind, PreparedPayment, private}, + signing::active_signer_for_intent, + }, + error::{Error, Result}, + evm::{FunctionCall, send_function_with_gas, send_native_with_gas}, + output::with_loading_handle, + profiles::{ + Error as ProfileError, + model::{FetchPaymentIntent, PublicSigningIntent}, + }, + runtime::BeamApp, + transaction::{TransactionExecution, loading_message}, +}; + +pub(crate) async fn execute_payment( + app: &BeamApp, + payment: &PreparedPayment, +) -> Result { + if payment.private_recipient.is_some() { + if app.overrides.profile.is_some() { + return Err(ProfileError::PrivacyCapabilityUnsupported { + capability: "private-payment".to_string(), + } + .into()); + } + return private::execute_private_payment(app, payment).await; + } + + let asset = match payment.asset.kind.clone() { + PaymentAssetKind::Native => "native".to_string(), + PaymentAssetKind::Erc20(token) => format!("{token:#x}"), + }; + let signer = active_signer_for_intent( + app, + PublicSigningIntent::FetchPayment(FetchPaymentIntent { + wallet: format!("{:#x}", payment.payer), + scheme: payment.scheme.clone(), + origin: payment.network.clone(), + chain: payment.chain.key.clone(), + asset, + recipient: format!("{:#x}", payment.recipient), + amount: payment.amount.to_string(), + private_payment: false, + }), + ) + .await?; + let action = format!( + "payment of {} {} to {:#x}", + payment.amount_display, payment.asset.label, payment.recipient + ); + let client = payment.client.clone(); + let recipient = payment.recipient; + let amount = payment.amount; + let gas = payment.transaction_gas(); + + let execution = with_loading_handle( + app.output_mode, + format!("Sending {action} and waiting for confirmation..."), + |loading| async move { + match payment.asset.kind.clone() { + PaymentAssetKind::Native => { + send_native_with_gas( + &client, + signer.as_ref(), + recipient, + amount, + Some(gas), + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + } + PaymentAssetKind::Erc20(token) => { + let function = + parse_function("transfer(address,uint256)", StateMutability::NonPayable)?; + let args = vec![format!("{recipient:#x}"), amount.to_string()]; + + send_function_with_gas( + &client, + signer.as_ref(), + FunctionCall { + args: &args, + contract: token, + function: &function, + value: U256::zero(), + }, + Some(gas), + move |update| loading.set_message(loading_message(&action, &update)), + tokio::signal::ctrl_c(), + ) + .await + } + } + }, + ) + .await?; + + let tx_hash = match execution { + TransactionExecution::Confirmed(outcome) => outcome.tx_hash, + TransactionExecution::Pending(pending) => { + return Err(Error::FetchPaymentUnconfirmed { + tx_hash: pending.tx_hash, + }); + } + TransactionExecution::Dropped(dropped) => { + return Err(Error::FetchPaymentUnconfirmed { + tx_hash: dropped.tx_hash, + }); + } + }; + + Ok(ExecutedPayment { + accepted: payment.accepted.clone(), + network: payment.network.clone(), + proof: json!({ + "amount": payment.amount.to_string(), + "asset": payment.asset_id, + "chainId": payment.chain.chain_id, + "from": format!("{:#x}", payment.payer), + "kind": "beam-evm-transfer", + "network": payment.network, + "to": format!("{:#x}", payment.recipient), + "txHash": tx_hash, + }), + scheme: payment.scheme.clone(), + source: Some(format!( + "did:pkh:eip155:{}:{:#x}", + payment.chain.chain_id, payment.payer + )), + }) +} diff --git a/pkg/beam-cli/src/commands/interactive_parse.rs b/pkg/beam-cli/src/commands/interactive_parse.rs index a1841cc..25c2114 100644 --- a/pkg/beam-cli/src/commands/interactive_parse.rs +++ b/pkg/beam-cli/src/commands/interactive_parse.rs @@ -70,6 +70,7 @@ pub(crate) fn merge_overrides( InvocationOverrides { chain: new.chain.clone().or(base.chain.clone()), from: new.from.clone().or(base.from.clone()), + profile: new.profile.clone().or(base.profile.clone()), rpc, } } @@ -150,6 +151,7 @@ fn is_cli_subcommand_invocation(command: &str, args: &[String]) -> bool { | "export-recovery-phrase" | "import-recovery-phrase" | "list" + | "change-password" | "rename" | "address" | "use" diff --git a/pkg/beam-cli/src/commands/mod.rs b/pkg/beam-cli/src/commands/mod.rs index a8fa705..77fe448 100644 --- a/pkg/beam-cli/src/commands/mod.rs +++ b/pkg/beam-cli/src/commands/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod interactive_parse; pub(crate) mod interactive_state; mod interactive_suggestion; pub mod privacy; +pub mod profiles; pub mod rpc; pub(crate) mod signing; pub(crate) mod token_report; @@ -25,6 +26,8 @@ pub mod txn; pub mod update; pub mod util; pub mod wallet; +pub(crate) mod wallet_default; +pub(crate) mod wallet_password; pub(crate) mod wallet_private_key; pub(crate) mod wallet_recovery; pub(crate) mod wallet_secret; @@ -39,6 +42,7 @@ pub async fn run(app: &BeamApp, command: Command) -> Result<()> { Command::Rpc { action } => rpc::run(app, action).await, Command::Tokens { action } => tokens::run(app, action).await, Command::Apps { action } => apps::run(app, action).await, + Command::Profiles { action } => profiles::run(app, action).await, Command::X(args) => apps::run_app(app, args).await, Command::Privacy { action } => privacy::run(app, action).await, Command::Balance(args) => balance::run(app, args).await, @@ -54,6 +58,7 @@ pub async fn run(app: &BeamApp, command: Command) -> Result<()> { Command::Update => { update::run_update(&app.overrides, app.output_mode, app.color_mode).await } + Command::ProfileDaemon(_) => Ok(()), Command::RefreshUpdateStatus => Ok(()), } } diff --git a/pkg/beam-cli/src/commands/profiles/grants.rs b/pkg/beam-cli/src/commands/profiles/grants.rs new file mode 100644 index 0000000..480fd22 --- /dev/null +++ b/pkg/beam-cli/src/commands/profiles/grants.rs @@ -0,0 +1,162 @@ +use contextful::ResultContextExt; +use serde_json::json; +use sha2::{Digest, Sha256}; + +use crate::{ + apps::{ + approvals::{ApprovalStore, plan_hash}, + model::ActionPlan, + }, + cli::{ProfileAppGrantArgs, ProfileCommandGrantArgs, ProfileCommandKind}, + profiles::{ + self, Error as ProfileError, + model::{ActionGrantBinding, ActionGrantStep, AppGrant, CommandGrant, PublicCommandKind}, + store::{self, parse_duration_secs}, + }, + runtime::BeamApp, +}; + +pub async fn app_grant(app: &BeamApp, args: ProfileAppGrantArgs) -> profiles::Result { + let plan = grant_plan(app, &args).await?; + let ttl_secs = args.ttl.as_deref().map(parse_duration_secs).transpose()?; + let expires_at = ttl_secs.map(|ttl| store::now().saturating_add(ttl)); + let seed = serde_json::to_value((&args.app, &args.command, &args.plan_hash)) + .context("encode app grant seed")?; + let mut grant = AppGrant { + id: grant_id("app", &seed), + app_id: args.app.clone(), + registry_url: args.registry_url.clone(), + app_version: args.version.clone(), + manifest_digest: args.manifest_digest.clone(), + module_digest: args.module_digest.clone(), + command: args.command.clone(), + wallet: args.wallet.clone(), + chain: args.chain.clone(), + plan_hash: args.plan_hash.clone(), + bindings: Vec::new(), + constraints: Vec::new(), + steps: Vec::new(), + expires_at, + max_gas: args.max_gas, + cumulative_budget: args.budget, + allow_unlimited_approval: args.allow_unlimited_approval, + }; + if let Some((plan, hash)) = plan { + apply_plan(&mut grant, plan, hash); + } + Ok(grant) +} + +pub fn command_grant(args: ProfileCommandGrantArgs) -> profiles::Result { + let ttl_secs = args.ttl.as_deref().map(parse_duration_secs).transpose()?; + let command = match args.command { + ProfileCommandKind::NativeTransfer => PublicCommandKind::NativeTransfer, + ProfileCommandKind::Erc20Transfer => PublicCommandKind::Erc20Transfer, + ProfileCommandKind::Erc20Approval => PublicCommandKind::Erc20Approval, + ProfileCommandKind::ContractTransaction => PublicCommandKind::ContractTransaction, + ProfileCommandKind::FetchPayment => PublicCommandKind::FetchPayment, + }; + Ok(CommandGrant { + id: grant_id("cmd", &command_seed(&args)), + command, + chain: args.chain, + token: args.token, + recipient: args.recipient, + target: args.target, + selector: args.selector, + spender: args.spender, + max_native_value: args.max_native, + max_token_amount: args.max_token, + max_gas: args.max_gas, + cumulative_budget: args.budget, + ttl_secs, + allow_unlimited_approval: args.allow_unlimited_approval, + }) +} + +async fn grant_plan( + app: &BeamApp, + args: &ProfileAppGrantArgs, +) -> profiles::Result> { + if let Some(approval_id) = args.approval_id.as_deref() { + let approval = ApprovalStore::load(&app.paths.root) + .await + .map_err(profile_policy_error)? + .find(approval_id) + .await + .map_err(profile_policy_error)?; + return Ok(Some((approval.plan, approval.plan_hash))); + } + if let Some(path) = args.plan_json.as_ref() { + let bytes = std::fs::read(path).context("read profile app action plan json")?; + let plan = + serde_json::from_slice::(&bytes).context("decode profile action plan")?; + let hash = plan_hash(&plan).map_err(profile_policy_error)?; + return Ok(Some((plan, hash))); + } + Ok(None) +} + +fn apply_plan(grant: &mut AppGrant, plan: ActionPlan, hash: String) { + grant.app_id = plan.app_id.clone(); + grant.app_version = Some(plan.app_version.clone()); + grant.manifest_digest = Some(plan.manifest_sha256.clone()); + grant.module_digest = Some(plan.wasm_sha256.clone()); + grant.command = Some(plan.command.clone()); + grant.wallet = plan.wallet.clone(); + grant.chain = Some(plan.chain.clone()); + grant.plan_hash = Some(hash); + grant.bindings = plan + .bindings + .into_iter() + .map(|binding| ActionGrantBinding { + key: binding.key, + value: binding.value, + }) + .collect(); + grant.constraints = plan.constraints; + grant.steps = plan + .steps + .into_iter() + .map(|step| ActionGrantStep { + kind: step.kind, + target: step.target, + selector: step.selector, + spender: step.spender, + value: step.value, + metadata: step.metadata, + }) + .collect(); + grant.expires_at = Some(grant.expires_at.map_or(plan.expires_at, |expires_at| { + expires_at.min(plan.expires_at) + })); +} + +fn command_seed(args: &ProfileCommandGrantArgs) -> serde_json::Value { + json!({ + "budget": args.budget.clone(), + "chain": args.chain.clone(), + "command": format!("{:?}", args.command), + "max_gas": args.max_gas.clone(), + "max_native": args.max_native.clone(), + "max_token": args.max_token.clone(), + "recipient": args.recipient.clone(), + "selector": args.selector.clone(), + "spender": args.spender.clone(), + "target": args.target.clone(), + "token": args.token.clone(), + "ttl": args.ttl.clone(), + }) +} + +fn grant_id(prefix: &str, value: &serde_json::Value) -> String { + let bytes = serde_json::to_vec(value).unwrap_or_default(); + let digest = Sha256::digest(bytes); + format!("{prefix}_{}", hex::encode(&digest[..8])) +} + +fn profile_policy_error(err: impl std::error::Error) -> ProfileError { + ProfileError::PolicyDenied { + reason: err.to_string(), + } +} diff --git a/pkg/beam-cli/src/commands/profiles/mod.rs b/pkg/beam-cli/src/commands/profiles/mod.rs new file mode 100644 index 0000000..eccc04a --- /dev/null +++ b/pkg/beam-cli/src/commands/profiles/mod.rs @@ -0,0 +1,289 @@ +// lint-long-file-override allow-max-lines=295 +mod grants; +mod session_helpers; + +use serde_json::json; + +use crate::{ + cli::{ + ProfileCreateArgs, ProfileGrantArgs, ProfileGrantKind, ProfileRevokeArgs, + ProfileUnlockArgs, ProfilesAction, + }, + error::Result, + keystore::{decrypt_private_key, prompt_existing_password}, + output::CommandOutput, + profiles::{ + Error as ProfileError, ledger, + model::{ProfilePolicy, ProfileRecord}, + session::{self, ProfileSession}, + signer, + store::{self, integrity_key, parse_duration_secs, validate_profile_name}, + }, + runtime::BeamApp, + table::render_table, +}; + +use grants::{app_grant, command_grant}; +use session_helpers::{shell_escape, spawn_daemon, wait_for_daemon}; + +const DEFAULT_PROFILE_TTL_SECS: u64 = 60 * 60; + +pub async fn run(app: &BeamApp, action: ProfilesAction) -> Result<()> { + match action { + ProfilesAction::Create(args) => create(app, args).await, + ProfilesAction::List => list(app).await, + ProfilesAction::Show { profile } => show(app, &profile).await, + ProfilesAction::Grant(args) => grant(app, *args).await, + ProfilesAction::Revoke(args) => revoke(app, args).await, + ProfilesAction::Remove { profile } => remove(app, &profile).await, + ProfilesAction::Ledger { profile } => ledger(app, &profile).await, + ProfilesAction::Unlock(args) => unlock(app, args).await, + ProfilesAction::Lock { profile } => lock(app, &profile).await, + ProfilesAction::Sessions => sessions(app).await, + } +} + +async fn create(app: &BeamApp, args: ProfileCreateArgs) -> Result<()> { + validate_profile_name(&args.profile)?; + let wallet = app.resolve_wallet(&args.from).await?; + let password = prompt_existing_password()?; + if password.is_empty() { + return Err(ProfileError::EmptyPasswordWallet.into()); + } + let secret_key = decrypt_private_key(&wallet, &password)?; + let now = store::now(); + let profile = ProfileRecord { + name: args.profile.clone(), + wallet_address: wallet.address.clone(), + wallet_selector: args.from, + created_at: now, + updated_at: now, + default_ttl_secs: DEFAULT_PROFILE_TTL_SECS, + policy: ProfilePolicy::default(), + integrity: None, + }; + store::insert( + &app.paths.root, + profile.clone(), + &integrity_key(&secret_key), + ) + .await?; + CommandOutput::new( + format!("Created profile {}", profile.name), + json!({ "profile": profile.name, "wallet": profile.wallet_address }), + ) + .print(app.output_mode) +} + +async fn list(app: &BeamApp) -> Result<()> { + let profiles = store::list(&app.paths.root).await?; + let rows = profiles + .iter() + .map(|profile| vec![profile.name.clone(), profile.wallet_address.clone()]) + .collect::>(); + CommandOutput::new( + render_table(&["Profile", "Wallet"], &rows), + json!({ "profiles": profiles }), + ) + .print(app.output_mode) +} + +async fn show(app: &BeamApp, profile: &str) -> Result<()> { + let profile = store::find(&app.paths.root, profile).await?; + CommandOutput::new(render_profile(&profile), json!({ "profile": profile })) + .print(app.output_mode) +} + +async fn grant(app: &BeamApp, args: ProfileGrantArgs) -> Result<()> { + let secret_key = profile_secret(app, &args.profile).await?; + let key = integrity_key(&secret_key); + let output = match args.grant { + ProfileGrantKind::Command(grant_args) => { + let grant = command_grant(grant_args)?; + let grant_id = grant.id.clone(); + store::update_verified(&app.paths.root, &args.profile, &key, |profile| { + profile.policy.command_grants.push(grant); + Ok(()) + }) + .await?; + json!({ "grant_id": grant_id, "kind": "command" }) + } + ProfileGrantKind::App(grant_args) => { + let grant = app_grant(app, grant_args).await?; + let grant_id = grant.id.clone(); + store::update_verified(&app.paths.root, &args.profile, &key, |profile| { + profile.policy.app_grants.push(grant); + Ok(()) + }) + .await?; + json!({ "grant_id": grant_id, "kind": "app" }) + } + }; + CommandOutput::new("Granted profile capability".to_string(), output).print(app.output_mode) +} + +async fn revoke(app: &BeamApp, args: ProfileRevokeArgs) -> Result<()> { + let secret_key = profile_secret(app, &args.profile).await?; + let key = integrity_key(&secret_key); + store::update_verified(&app.paths.root, &args.profile, &key, |profile| { + let before = profile.policy.command_grants.len() + profile.policy.app_grants.len(); + profile + .policy + .command_grants + .retain(|grant| grant.id != args.grant_id); + profile + .policy + .app_grants + .retain(|grant| grant.id != args.grant_id); + let after = profile.policy.command_grants.len() + profile.policy.app_grants.len(); + if before == after { + return Err(ProfileError::GrantNotFound { + grant_id: args.grant_id.clone(), + }); + } + Ok(()) + }) + .await?; + CommandOutput::new( + format!("Revoked {}", args.grant_id), + json!({ "grant_id": args.grant_id, "revoked": true }), + ) + .print(app.output_mode) +} + +async fn remove(app: &BeamApp, profile: &str) -> Result<()> { + let secret_key = profile_secret(app, profile).await?; + store::remove_verified(&app.paths.root, profile, &integrity_key(&secret_key)).await?; + let _ = session::remove(&app.paths.root, profile).await; + CommandOutput::new( + format!("Removed profile {profile}"), + json!({ "profile": profile, "removed": true }), + ) + .print(app.output_mode) +} + +async fn ledger(app: &BeamApp, profile: &str) -> Result<()> { + let state = ledger::load(&app.paths.root).await?.get().await; + let entries = state + .entries + .into_iter() + .filter(|entry| entry.profile == profile) + .collect::>(); + let rows = entries + .iter() + .map(|entry| { + vec![ + entry.id.clone(), + format!("{:?}", entry.status), + entry.chain.clone(), + entry.amount.clone(), + entry.tx_hash.clone().unwrap_or_default(), + ] + }) + .collect::>(); + CommandOutput::new( + render_table(&["Entry", "Status", "Chain", "Amount", "Tx"], &rows), + json!({ "entries": entries }), + ) + .print(app.output_mode) +} + +async fn unlock(app: &BeamApp, args: ProfileUnlockArgs) -> Result<()> { + let profile = store::find(&app.paths.root, &args.profile).await?; + let secret_key = profile_secret(app, &args.profile).await?; + store::verify_profile(&profile, &integrity_key(&secret_key))?; + let ttl = args + .ttl + .as_deref() + .map(parse_duration_secs) + .transpose()? + .unwrap_or(profile.default_ttl_secs); + let token = session::new_token(); + let expires_at = store::now().saturating_add(ttl); + let socket = session::socket_path(&app.paths.root, &profile.name); + spawn_daemon(app, &profile.name, &socket, &token, expires_at, &secret_key)?; + let session = ProfileSession { + profile: profile.name.clone(), + wallet_address: profile.wallet_address.clone(), + socket, + token, + created_at: store::now(), + expires_at, + }; + wait_for_daemon(&session).await?; + session::upsert(&app.paths.root, session.clone()).await?; + let message = if args.print_env { + format!( + "export BEAM_PROFILE={}\nexport BEAM_PROFILE_TOKEN={}", + shell_escape(&session.profile), + shell_escape(&session.token) + ) + } else { + format!( + "Unlocked profile {} until {}", + session.profile, session.expires_at + ) + }; + CommandOutput::new( + message, + json!({ "expires_at": session.expires_at, "profile": session.profile }), + ) + .print(app.output_mode) +} + +async fn lock(app: &BeamApp, profile: &str) -> Result<()> { + if let Ok(session) = session::active(&app.paths.root, profile).await { + let _ = signer::lock(&session).await; + } + session::remove(&app.paths.root, profile).await?; + CommandOutput::new( + format!("Locked profile {profile}"), + json!({ "locked": true, "profile": profile }), + ) + .print(app.output_mode) +} + +async fn sessions(app: &BeamApp) -> Result<()> { + let sessions = session::list(&app.paths.root).await?; + let rows = sessions + .iter() + .map(|session| { + vec![ + session.profile.clone(), + session.wallet_address.clone(), + session.expires_at.to_string(), + ] + }) + .collect::>(); + CommandOutput::new( + render_table(&["Profile", "Wallet", "Expires"], &rows), + json!({ "sessions": sessions }), + ) + .print(app.output_mode) +} + +async fn profile_secret(app: &BeamApp, profile: &str) -> Result> { + let profile = store::find(&app.paths.root, profile).await?; + let wallet = app.resolve_wallet(&profile.wallet_address).await?; + let password = prompt_existing_password()?; + if password.is_empty() { + return Err(ProfileError::EmptyPasswordWallet.into()); + } + decrypt_private_key(&wallet, &password) +} + +fn render_profile(profile: &ProfileRecord) -> String { + let rows = vec![ + vec!["profile".to_string(), profile.name.clone()], + vec!["wallet".to_string(), profile.wallet_address.clone()], + vec![ + "command grants".to_string(), + profile.policy.command_grants.len().to_string(), + ], + vec![ + "app grants".to_string(), + profile.policy.app_grants.len().to_string(), + ], + ]; + render_table(&["Field", "Value"], &rows) +} diff --git a/pkg/beam-cli/src/commands/profiles/session_helpers.rs b/pkg/beam-cli/src/commands/profiles/session_helpers.rs new file mode 100644 index 0000000..35d52ee --- /dev/null +++ b/pkg/beam-cli/src/commands/profiles/session_helpers.rs @@ -0,0 +1,87 @@ +#[cfg(unix)] +use std::os::unix::process::CommandExt; +use std::{ + io::Write, + process::{Command as ProcessCommand, Stdio}, + time::Duration, +}; + +use contextful::ResultContextExt; +use tokio::time::sleep; + +use crate::{ + error::Result, + profiles::{Error as ProfileError, session::ProfileSession, signer}, + runtime::BeamApp, +}; + +pub fn shell_escape(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +pub fn spawn_daemon( + app: &BeamApp, + profile: &str, + socket: &std::path::Path, + token: &str, + expires_at: u64, + secret_key: &[u8], +) -> Result<()> { + let executable = std::env::current_exe().context("resolve beam executable")?; + let mut command = ProcessCommand::new(executable); + command + .arg("--no-update-check") + .arg("__profile-daemon") + .arg("--root") + .arg(&app.paths.root) + .arg("--profile") + .arg(profile) + .arg("--socket") + .arg(socket) + .arg("--session") + .arg(token) + .arg("--expires-at") + .arg(expires_at.to_string()) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + // SAFETY: `pre_exec` runs in the child after fork and before exec. The + // closure only installs an ignored `SIGHUP` disposition and calls + // `setsid(2)`, both async-signal-safe libc operations, so the profile + // daemon survives short-lived agent PTYs. No memory shared with other + // threads is accessed. + unsafe { + command.pre_exec(|| { + libc::signal(libc::SIGHUP, libc::SIG_IGN); + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + let mut child = command.spawn().context("spawn beam profile daemon")?; + let mut stdin = child + .stdin + .take() + .ok_or(ProfileError::InvalidDaemonRequest)?; + stdin + .write_all(hex::encode(secret_key).as_bytes()) + .context("send profile daemon key")?; + Ok(()) +} + +pub async fn wait_for_daemon(session: &ProfileSession) -> Result<()> { + for _ in 0..20 { + if signer::ping(session).await.is_ok() { + return Ok(()); + } + sleep(Duration::from_millis(50)).await; + } + Err(ProfileError::DaemonConnectionFailed { + profile: session.profile.clone(), + } + .into()) +} diff --git a/pkg/beam-cli/src/commands/signing.rs b/pkg/beam-cli/src/commands/signing.rs index d3f6fc3..57e81c6 100644 --- a/pkg/beam-cli/src/commands/signing.rs +++ b/pkg/beam-cli/src/commands/signing.rs @@ -1,14 +1,46 @@ use crate::{ error::{Error, Result}, keystore::{StoredWallet, decrypt_private_key, prompt_existing_password}, + profiles::{model::PublicSigningIntent, session, signer::ProfileSigner}, runtime::BeamApp, - signer::KeySigner, + signer::{KeySigner, Signer}, }; pub(crate) async fn prompt_active_signer(app: &BeamApp) -> Result { prompt_active_signer_with(app, prompt_existing_password).await } +pub(crate) async fn active_signing_address(app: &BeamApp) -> Result { + match app.overrides.profile.as_deref() { + Some(profile) => { + let session = session::active(&app.paths.root, profile).await?; + session + .wallet_address + .parse() + .map_err(|_| Error::InvalidAddress { + value: session.wallet_address, + }) + } + None => app.active_address().await, + } +} + +pub(crate) async fn active_signer_for_intent( + app: &BeamApp, + intent: PublicSigningIntent, +) -> Result> { + match app.overrides.profile.as_deref() { + Some(profile) => { + let mut session = session::active(&app.paths.root, profile).await?; + if let Ok(token) = std::env::var("BEAM_PROFILE_TOKEN") { + session.token = token; + } + Ok(Box::new(ProfileSigner::new(session, intent)?)) + } + None => Ok(Box::new(prompt_active_signer(app).await?)), + } +} + pub(crate) async fn prompt_active_private_key(app: &BeamApp) -> Result<[u8; 32]> { let wallet = app.active_wallet().await?; let password = prompt_existing_password()?; diff --git a/pkg/beam-cli/src/commands/transfer.rs b/pkg/beam-cli/src/commands/transfer.rs index f5181da..38cd853 100644 --- a/pkg/beam-cli/src/commands/transfer.rs +++ b/pkg/beam-cli/src/commands/transfer.rs @@ -2,13 +2,14 @@ use serde_json::json; use crate::{ cli::TransferArgs, - commands::signing::prompt_active_signer, + commands::signing::{active_signer_for_intent, active_signing_address}, error::Result, evm::{parse_units, send_native}, output::{ CommandOutput, confirmed_transaction_message, dropped_transaction_message, pending_transaction_message, with_loading_handle, }, + profiles::model::{PublicSigningIntent, TransferIntent}, runtime::BeamApp, transaction::{TransactionExecution, loading_message}, }; @@ -17,7 +18,18 @@ pub async fn run(app: &BeamApp, args: TransferArgs) -> Result<()> { let (chain, client) = app.active_chain_client().await?; let to = app.resolve_wallet_or_address(&args.to).await?; let amount = parse_units(&args.amount, 18)?; - let signer = prompt_active_signer(app).await?; + let wallet = active_signing_address(app).await?; + let signer = active_signer_for_intent( + app, + PublicSigningIntent::NativeTransfer(TransferIntent { + wallet: format!("{wallet:#x}"), + chain: chain.entry.key.clone(), + recipient: format!("{to:#x}"), + amount: amount.to_string(), + asset: "native".to_string(), + }), + ) + .await?; let action = format!( "transfer of {} {} to {to:#x}", args.amount, chain.entry.native_symbol @@ -28,7 +40,7 @@ pub async fn run(app: &BeamApp, args: TransferArgs) -> Result<()> { |loading| async move { send_native( &client, - &signer, + signer.as_ref(), to, amount, move |update| loading.set_message(loading_message(&action, &update)), diff --git a/pkg/beam-cli/src/commands/wallet.rs b/pkg/beam-cli/src/commands/wallet.rs index 7ef418f..d896593 100644 --- a/pkg/beam-cli/src/commands/wallet.rs +++ b/pkg/beam-cli/src/commands/wallet.rs @@ -3,8 +3,8 @@ use contextful::ResultContextExt; use serde_json::json; use super::{ - wallet_private_key, wallet_recovery, - wallet_secret::{generate_secret_key, load_secret_key}, + wallet_default, wallet_password, wallet_private_key, wallet_recovery, + wallet_secret::{self, generate_secret_key, load_secret_key}, }; #[cfg(test)] use crate::keystore::validate_new_password; @@ -48,11 +48,14 @@ pub async fn run(app: &BeamApp, action: WalletAction) -> Result<()> { .await } WalletAction::List => list_wallets(app).await, + WalletAction::ChangePassword { wallet } => { + wallet_password::change_wallet_password(app, wallet.as_deref()).await + } WalletAction::Rename { name, new_name } => rename_wallet(app, &name, &new_name).await, WalletAction::Address { private_key_source } => { - show_address(app, &private_key_source).await + wallet_secret::show_address(app, &private_key_source).await } - WalletAction::Use { name } => use_wallet(app, &name).await, + WalletAction::Use { name } => wallet_default::use_wallet(app, &name).await, } } @@ -126,14 +129,6 @@ async fn list_wallets(app: &BeamApp) -> Result<()> { .print(app.output_mode) } -async fn show_address(app: &BeamApp, private_key_source: &PrivateKeySourceArgs) -> Result<()> { - let secret_key = load_secret_key(private_key_source)?; - let address = format!("{:#x}", wallet_address(&secret_key)?); - CommandOutput::new(address.clone(), json!({ "address": address })) - .compact(address) - .print(app.output_mode) -} - pub(crate) async fn rename_wallet(app: &BeamApp, name: &str, new_name: &str) -> Result<()> { let wallet = app.resolve_wallet(name).await?; let address = wallet.address.clone(); @@ -189,27 +184,6 @@ pub(crate) async fn rename_wallet(app: &BeamApp, name: &str, new_name: &str) -> .print(app.output_mode) } -async fn use_wallet(app: &BeamApp, name: &str) -> Result<()> { - let wallet = app.resolve_wallet(name).await?; - let name = wallet.name.clone(); - - app.config_store - .update(|config| config.default_wallet = Some(name.clone())) - .await - .context("persist beam default wallet")?; - - let name = sanitize_control_chars(&name); - CommandOutput::new( - format!("Default wallet set to {name} ({})", wallet.address), - json!({ - "address": wallet.address, - "name": wallet.name, - }), - ) - .compact(name) - .print(app.output_mode) -} - pub(super) async fn store_wallet( app: &BeamApp, requested_name: Option, diff --git a/pkg/beam-cli/src/commands/wallet_default.rs b/pkg/beam-cli/src/commands/wallet_default.rs new file mode 100644 index 0000000..e2c5e70 --- /dev/null +++ b/pkg/beam-cli/src/commands/wallet_default.rs @@ -0,0 +1,27 @@ +use contextful::ResultContextExt; +use serde_json::json; + +use crate::{ + error::Result, human_output::sanitize_control_chars, output::CommandOutput, runtime::BeamApp, +}; + +pub(crate) async fn use_wallet(app: &BeamApp, name: &str) -> Result<()> { + let wallet = app.resolve_wallet(name).await?; + let name = wallet.name.clone(); + + app.config_store + .update(|config| config.default_wallet = Some(name.clone())) + .await + .context("persist beam default wallet")?; + + let name = sanitize_control_chars(&name); + CommandOutput::new( + format!("Default wallet set to {name} ({})", wallet.address), + json!({ + "address": wallet.address, + "name": wallet.name, + }), + ) + .compact(name) + .print(app.output_mode) +} diff --git a/pkg/beam-cli/src/commands/wallet_password.rs b/pkg/beam-cli/src/commands/wallet_password.rs new file mode 100644 index 0000000..dcd7dd7 --- /dev/null +++ b/pkg/beam-cli/src/commands/wallet_password.rs @@ -0,0 +1,67 @@ +use contextful::ResultContextExt; +use serde_json::json; + +use crate::{ + error::{Error, Result}, + human_output::sanitize_control_chars, + keystore::{ + decrypt_private_key, encrypt_private_key, prompt_existing_password, prompt_new_password, + validate_new_password, + }, + output::CommandOutput, + runtime::BeamApp, +}; + +pub(crate) async fn change_wallet_password( + app: &BeamApp, + wallet_selector: Option<&str>, +) -> Result<()> { + let current_password = prompt_existing_password()?; + let new_password = prompt_new_password()?; + change_wallet_password_with_passwords(app, wallet_selector, ¤t_password, &new_password) + .await? + .print(app.output_mode) +} + +pub(crate) async fn change_wallet_password_with_passwords( + app: &BeamApp, + wallet_selector: Option<&str>, + current_password: &str, + new_password: &str, +) -> Result { + validate_new_password(new_password, new_password)?; + let wallet = match wallet_selector { + Some(selector) => app.resolve_wallet(selector).await?, + None => app.active_wallet().await?, + }; + let secret_key = decrypt_private_key(&wallet, current_password)?; + let encrypted_private_key = encrypt_private_key(&secret_key, new_password)?; + + let address = wallet.address.clone(); + let mut keystore = app.keystore_store.get().await; + let stored_wallet = keystore + .wallets + .iter_mut() + .find(|stored_wallet| stored_wallet.address.eq_ignore_ascii_case(&address)) + .ok_or_else(|| Error::WalletNotFound { + selector: address.clone(), + })?; + stored_wallet.encrypted_key = encrypted_private_key.encrypted_key; + stored_wallet.salt = encrypted_private_key.salt; + stored_wallet.kdf = encrypted_private_key.kdf; + + app.keystore_store + .set(keystore) + .await + .context("persist beam wallet password change")?; + + let display_name = sanitize_control_chars(&wallet.name); + Ok(CommandOutput::new( + format!("Changed password for wallet {display_name} ({address})"), + json!({ + "address": address, + "name": wallet.name, + }), + ) + .compact(format!("{display_name} {address}"))) +} diff --git a/pkg/beam-cli/src/commands/wallet_secret.rs b/pkg/beam-cli/src/commands/wallet_secret.rs index 429c048..ba5fab3 100644 --- a/pkg/beam-cli/src/commands/wallet_secret.rs +++ b/pkg/beam-cli/src/commands/wallet_secret.rs @@ -7,9 +7,27 @@ use rand::RngCore; use crate::{ cli::PrivateKeySourceArgs, error::{Error, Result}, - keystore::prompt_private_key, + keystore::{prompt_private_key, wallet_address}, + output::CommandOutput, + runtime::BeamApp, }; +pub(crate) async fn show_address( + app: &BeamApp, + private_key_source: &PrivateKeySourceArgs, +) -> Result<()> { + let secret_key = load_secret_key(private_key_source)?; + let address = format!("{:#x}", wallet_address(&secret_key)?); + CommandOutput::new( + address.clone(), + serde_json::json!({ + "address": address, + }), + ) + .compact(address) + .print(app.output_mode) +} + pub(crate) fn load_secret_key(private_key_source: &PrivateKeySourceArgs) -> Result> { let private_key = read_private_key(private_key_source)?; let secret_key = parse_secret_key(&private_key)?; diff --git a/pkg/beam-cli/src/error.rs b/pkg/beam-cli/src/error.rs index b1ac036..9f452af 100644 --- a/pkg/beam-cli/src/error.rs +++ b/pkg/beam-cli/src/error.rs @@ -2,7 +2,7 @@ use contextful::{FromContextful, InternalError}; use contracts::U256; -use crate::apps::Error as AppError; +use crate::{apps::Error as AppError, profiles::Error as ProfileError}; pub type Result = std::result::Result; @@ -216,6 +216,9 @@ pub enum Error { #[error("[beam-cli] app error")] App(#[source] AppError), + #[error("[beam-cli] profile error")] + Profile(#[source] ProfileError), + #[error("[beam-cli] invalid rpc url: {value}")] InvalidRpcUrl { value: String }, @@ -394,3 +397,9 @@ impl From for Error { Self::App(err) } } + +impl From for Error { + fn from(err: ProfileError) -> Self { + Self::Profile(err) + } +} diff --git a/pkg/beam-cli/src/evm.rs b/pkg/beam-cli/src/evm.rs index c05efae..2459255 100644 --- a/pkg/beam-cli/src/evm.rs +++ b/pkg/beam-cli/src/evm.rs @@ -156,7 +156,7 @@ pub async fn call_function( }) } -pub async fn send_native( +pub async fn send_native( client: &Client, signer: &S, to: Address, @@ -167,7 +167,7 @@ pub async fn send_native( send_native_with_gas(client, signer, to, amount, None, on_status, cancel).await } -pub async fn send_native_with_gas( +pub async fn send_native_with_gas( client: &Client, signer: &S, to: Address, @@ -181,7 +181,7 @@ pub async fn send_native_with_gas( submit_transaction(client, signer, tx, on_status, cancel).await } -pub async fn send_function( +pub async fn send_function( client: &Client, signer: &S, call: FunctionCall<'_>, @@ -191,7 +191,7 @@ pub async fn send_function( send_function_with_gas(client, signer, call, None, on_status, cancel).await } -pub async fn send_function_with_gas( +pub async fn send_function_with_gas( client: &Client, signer: &S, call: FunctionCall<'_>, @@ -212,7 +212,7 @@ pub async fn send_function_with_gas( submit_transaction(client, signer, tx, on_status, cancel).await } -pub async fn send_calldata_with_fee_report( +pub async fn send_calldata_with_fee_report( client: &Client, signer: &S, transaction: CalldataTransaction, @@ -332,7 +332,7 @@ pub fn transaction_fee_json(gas: &TransactionGas) -> serde_json::Value { } } -async fn submit_transaction( +async fn submit_transaction( client: &Client, signer: &S, transaction: TransactionParameters, diff --git a/pkg/beam-cli/src/main.rs b/pkg/beam-cli/src/main.rs index f9e9b08..3c3778e 100644 --- a/pkg/beam-cli/src/main.rs +++ b/pkg/beam-cli/src/main.rs @@ -15,6 +15,7 @@ mod known_tokens; mod output; mod privacy; mod privacy_config; +mod profiles; mod prompts; mod recovery_phrase; mod runtime; @@ -68,13 +69,21 @@ async fn run_cli_with_paths(cli: Cli, paths: Option) -> Result<()> { rpc, from, chain, + profile, output, color, no_update_check, } = cli; - let overrides = runtime::InvocationOverrides { chain, from, rpc }; + let profile = profile.or_else(|| std::env::var("BEAM_PROFILE").ok()); + let overrides = runtime::InvocationOverrides { + chain, + from, + profile, + rpc, + }; let command = match command { Some(Command::Util { action }) => return commands::util::run(output, action), + Some(Command::ProfileDaemon(args)) => return profiles::daemon::run(args).await, // Self-update must remain available even when local Beam state is corrupted. Some(Command::Update) => { return commands::update::run_update(&overrides, output, color).await; diff --git a/pkg/beam-cli/src/profiles/daemon.rs b/pkg/beam-cli/src/profiles/daemon.rs new file mode 100644 index 0000000..fd582da --- /dev/null +++ b/pkg/beam-cli/src/profiles/daemon.rs @@ -0,0 +1,213 @@ +// lint-long-file-override allow-max-lines=220 +use std::os::unix::fs::PermissionsExt; + +use contextful::ResultContextExt; +use tokio::{ + fs, + io::{AsyncReadExt, AsyncWriteExt}, + net::{UnixListener, UnixStream}, + time::{Duration, timeout}, +}; +use web3::{Web3, transports::Http}; + +use crate::{ + cli::ProfileDaemonArgs, + error, + profiles::{ + Error, Result, ledger, policy, + store::{self, integrity_key, now}, + wire::{DaemonRequest, DaemonResponse, WireSignedTransaction}, + }, + signer::{KeySigner, Signer}, +}; + +pub async fn run(args: ProfileDaemonArgs) -> error::Result<()> { + run_inner(args).await?; + Ok(()) +} + +async fn run_inner(args: ProfileDaemonArgs) -> Result<()> { + let mut stdin = String::new(); + tokio::io::stdin() + .read_to_string(&mut stdin) + .await + .context("read profile daemon key")?; + let secret_key = hex::decode(stdin.trim()).map_err(|_| Error::InvalidDaemonRequest)?; + let signer = KeySigner::from_slice(&secret_key).map_err(|_| Error::InvalidDaemonRequest)?; + let integrity_key = integrity_key(&secret_key); + + if let Some(parent) = args.socket.parent() { + fs::create_dir_all(parent) + .await + .context("create profile daemon socket directory")?; + } + if args.socket.exists() { + fs::remove_file(&args.socket) + .await + .context("remove stale profile daemon socket")?; + } + let listener = UnixListener::bind(&args.socket).context("bind profile daemon socket")?; + std::fs::set_permissions(&args.socket, std::fs::Permissions::from_mode(0o600)) + .context("set profile daemon socket permissions")?; + + loop { + if now() >= args.expires_at { + break; + } + let remaining = args.expires_at.saturating_sub(now()).max(1); + let accepted = timeout(Duration::from_secs(remaining), listener.accept()).await; + let Ok(Ok((stream, _addr))) = accepted else { + break; + }; + let response = handle_request(&args, &signer, &integrity_key, stream).await; + if matches!(response, DaemonLoopResponse::Lock) { + break; + } + } + + if args.socket.exists() { + let _ = fs::remove_file(&args.socket).await; + } + Ok(()) +} + +enum DaemonLoopResponse { + Continue, + Lock, +} + +async fn handle_request( + args: &ProfileDaemonArgs, + signer: &KeySigner, + integrity_key: &[u8], + mut stream: UnixStream, +) -> DaemonLoopResponse { + let response = match read_request(&mut stream).await { + Ok(request) => execute_request(args, signer, integrity_key, request).await, + Err(err) => Err(err), + }; + let lock = matches!(response, Ok(DaemonResponse::Ok)) && now() >= args.expires_at; + let response = response.unwrap_or_else(|err| DaemonResponse::Error { + message: if matches!(err, Error::SessionTokenRejected) { + "session-token-rejected".to_string() + } else { + err.to_string() + }, + }); + let _ = write_response(&mut stream, &response).await; + if lock || matches!(response, DaemonResponse::Ok) && should_stop(args).await { + DaemonLoopResponse::Lock + } else { + DaemonLoopResponse::Continue + } +} + +async fn execute_request( + args: &ProfileDaemonArgs, + signer: &KeySigner, + integrity_key: &[u8], + request: DaemonRequest, +) -> Result { + ensure_session(args, request_token(&request)?)?; + match request { + DaemonRequest::Ping { .. } => Ok(DaemonResponse::Ok), + DaemonRequest::Lock { .. } => { + fs::write(args.root.join("profiles").join("lock-sentinel"), b"") + .await + .context("write profile daemon lock sentinel")?; + Ok(DaemonResponse::Ok) + } + DaemonRequest::Rollback { ledger_id, .. } => { + ledger::mark_rolled_back(&args.root, integrity_key, &ledger_id).await?; + Ok(DaemonResponse::Ok) + } + DaemonRequest::Sign { + intent, + transaction, + .. + } => { + let profile = store::find(&args.root, &args.profile).await?; + store::verify_profile(&profile, integrity_key)?; + let ledger_store = ledger::load(&args.root).await?; + let ledger_state = ledger::verified_state(&ledger_store, integrity_key).await?; + let decision = policy::authorize(&profile, &ledger_state, &intent, &transaction)?; + let tx = transaction.to_parameters()?; + let dummy = dummy_web3()?; + let signed = signer + .sign_transaction(&dummy, tx) + .await + .map_err(|_| Error::InvalidDaemonRequest)?; + let entry = ledger::append_signed( + &args.root, + integrity_key, + &profile, + &decision.grant_id, + &intent, + transaction.gas.clone(), + format!("{:#x}", signed.transaction_hash), + ) + .await?; + Ok(DaemonResponse::Signed { + ledger_id: entry.id, + transaction: WireSignedTransaction::from_signed(&signed), + }) + } + } +} + +fn request_token(request: &DaemonRequest) -> Result<&str> { + match request { + DaemonRequest::Ping { token } + | DaemonRequest::Lock { token } + | DaemonRequest::Rollback { token, .. } + | DaemonRequest::Sign { token, .. } => Ok(token), + } +} + +fn ensure_session(args: &ProfileDaemonArgs, token: &str) -> Result<()> { + if args.expires_at <= now() { + return Err(Error::SessionExpired { + profile: args.profile.clone(), + }); + } + if token == args.session { + Ok(()) + } else { + Err(Error::SessionTokenRejected) + } +} + +async fn read_request(stream: &mut UnixStream) -> Result { + let mut bytes = Vec::new(); + stream + .read_to_end(&mut bytes) + .await + .context("read profile daemon request")?; + serde_json::from_slice(&bytes) + .context("decode profile daemon request") + .map_err(Into::into) +} + +async fn write_response(stream: &mut UnixStream, response: &DaemonResponse) -> Result<()> { + let bytes = serde_json::to_vec(response).context("encode profile daemon response")?; + stream + .write_all(&bytes) + .await + .context("write profile daemon response")?; + Ok(()) +} + +async fn should_stop(args: &ProfileDaemonArgs) -> bool { + let sentinel = args.root.join("profiles").join("lock-sentinel"); + if sentinel.exists() { + let _ = fs::remove_file(sentinel).await; + true + } else { + false + } +} + +fn dummy_web3() -> Result> { + let transport = Http::new("http://127.0.0.1:1").context("create daemon signing transport")?; + Ok(Web3::new(transport)) +} diff --git a/pkg/beam-cli/src/profiles/error.rs b/pkg/beam-cli/src/profiles/error.rs new file mode 100644 index 0000000..0cc9cf0 --- /dev/null +++ b/pkg/beam-cli/src/profiles/error.rs @@ -0,0 +1,60 @@ +use contextful::{FromContextful, InternalError}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error, FromContextful)] +pub enum Error { + #[error("[beam-cli/profiles] profile name cannot be empty")] + ProfileNameBlank, + + #[error("[beam-cli/profiles] profile already exists: {profile}")] + ProfileAlreadyExists { profile: String }, + + #[error("[beam-cli/profiles] profile not found: {profile}")] + ProfileNotFound { profile: String }, + + #[error("[beam-cli/profiles] grant not found: {grant_id}")] + GrantNotFound { grant_id: String }, + + #[error("[beam-cli/profiles] profiles require a non-empty wallet password")] + EmptyPasswordWallet, + + #[error("[beam-cli/profiles] profile integrity check failed: {profile}")] + ProfileIntegrityFailed { profile: String }, + + #[error("[beam-cli/profiles] ledger integrity check failed")] + LedgerIntegrityFailed, + + #[error("[beam-cli/profiles] profile session not found: {profile}")] + SessionNotFound { profile: String }, + + #[error("[beam-cli/profiles] profile session expired: {profile}")] + SessionExpired { profile: String }, + + #[error("[beam-cli/profiles] invalid profile session wallet address for {profile}: {address}")] + InvalidSessionWalletAddress { profile: String, address: String }, + + #[error("[beam-cli/profiles] profile daemon connection failed: {profile}")] + DaemonConnectionFailed { profile: String }, + + #[error("[beam-cli/profiles] profile daemon rejected session token")] + SessionTokenRejected, + + #[error("[beam-cli/profiles] profile policy denied signing request: {reason}")] + PolicyDenied { reason: String }, + + #[error("[beam-cli/profiles] privacy profile capability is not supported in v1: {capability}")] + PrivacyCapabilityUnsupported { capability: String }, + + #[error("[beam-cli/profiles] invalid profile duration: {value}")] + InvalidDuration { value: String }, + + #[error("[beam-cli/profiles] invalid profile amount: {value}")] + InvalidAmount { value: String }, + + #[error("[beam-cli/profiles] invalid profile daemon request")] + InvalidDaemonRequest, + + #[error("[beam-cli/profiles] internal error")] + Internal(#[from] InternalError), +} diff --git a/pkg/beam-cli/src/profiles/ledger.rs b/pkg/beam-cli/src/profiles/ledger.rs new file mode 100644 index 0000000..b77061b --- /dev/null +++ b/pkg/beam-cli/src/profiles/ledger.rs @@ -0,0 +1,228 @@ +// lint-long-file-override allow-max-lines=230 +use std::path::Path; + +use contextful::ResultContextExt; +use json_store::{FileAccess, InvalidJsonBehavior, JsonStore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::profiles::{ + Error, Result, + model::{ProfileRecord, PublicSigningIntent}, + store::now, +}; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct LedgerState { + #[serde(default)] + pub entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrity: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct LedgerEntry { + pub id: String, + pub profile: String, + pub grant_id: String, + pub created_at: u64, + pub status: LedgerStatus, + pub amount: String, + pub asset: String, + pub chain: String, + pub gas: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tx_hash: Option, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum LedgerStatus { + Signed, + RolledBack, +} + +pub async fn load(root: &Path) -> Result> { + Ok(JsonStore::new_with_invalid_json_behavior_and_access( + root.join("profiles"), + "ledger.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .context("load beam profile ledger")?) +} + +pub async fn append_signed( + root: &Path, + key: &[u8], + profile: &ProfileRecord, + grant_id: &str, + intent: &PublicSigningIntent, + gas: String, + tx_hash: String, +) -> Result { + let store = load(root).await?; + let mut state = verified_state(&store, key).await?; + let entry = entry_for_intent(profile, grant_id, intent, gas, tx_hash); + state.entries.push(entry.clone()); + sign_state(&mut state, key)?; + store.set(state).await.context("persist profile ledger")?; + Ok(entry) +} + +pub async fn mark_rolled_back(root: &Path, key: &[u8], ledger_id: &str) -> Result<()> { + let store = load(root).await?; + let mut state = verified_state(&store, key).await?; + for entry in &mut state.entries { + if entry.id == ledger_id { + entry.status = LedgerStatus::RolledBack; + } + } + sign_state(&mut state, key)?; + store + .set(state) + .await + .context("persist profile ledger rollback")?; + Ok(()) +} + +pub async fn verified_state(store: &JsonStore, key: &[u8]) -> Result { + let state = store.get().await; + verify_state(&state, key)?; + Ok(state) +} + +pub fn verify_state(state: &LedgerState, key: &[u8]) -> Result<()> { + if state.entries.is_empty() && state.integrity.is_none() { + return Ok(()); + } + let expected = state_digest(state, key)?; + match state.integrity.as_deref() { + Some(actual) if actual == expected => Ok(()), + _ => Err(Error::LedgerIntegrityFailed), + } +} + +pub fn sign_state(state: &mut LedgerState, key: &[u8]) -> Result<()> { + state.integrity = None; + state.integrity = Some(state_digest(state, key)?); + Ok(()) +} + +pub fn spent_for_grant(state: &LedgerState, grant_id: &str, asset: &str) -> contracts::U256 { + state + .entries + .iter() + .filter(|entry| entry.grant_id == grant_id && entry.asset == asset) + .filter(|entry| entry.status == LedgerStatus::Signed) + .filter_map(|entry| parse_u256(&entry.amount).ok()) + .fold(contracts::U256::zero(), |left, right| left + right) +} + +fn parse_u256(value: &str) -> Result { + if let Some(value) = value.strip_prefix("0x") { + return contracts::U256::from_str_radix(value, 16).map_err(|_| Error::InvalidAmount { + value: format!("0x{value}"), + }); + } + contracts::U256::from_dec_str(value).map_err(|_| Error::InvalidAmount { + value: value.to_string(), + }) +} + +fn state_digest(state: &LedgerState, key: &[u8]) -> Result { + let mut clone = state.clone(); + clone.integrity = None; + let bytes = serde_json::to_vec(&clone).context("encode profile ledger integrity payload")?; + let mut hasher = Sha256::new(); + hasher.update(key); + hasher.update(bytes); + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} + +fn entry_for_intent( + profile: &ProfileRecord, + grant_id: &str, + intent: &PublicSigningIntent, + gas: String, + tx_hash: String, +) -> LedgerEntry { + let now = now(); + let mut entry = LedgerEntry { + id: format!( + "led_{}", + short_hash(format!("{}:{grant_id}:{tx_hash}", profile.name)) + ), + profile: profile.name.clone(), + grant_id: grant_id.to_string(), + created_at: now, + status: LedgerStatus::Signed, + amount: "0".to_string(), + asset: "native".to_string(), + chain: String::new(), + gas, + app: None, + command: None, + plan_hash: None, + approval_id: None, + tx_hash: Some(tx_hash), + }; + apply_intent_fields(&mut entry, intent); + entry +} + +fn apply_intent_fields(entry: &mut LedgerEntry, intent: &PublicSigningIntent) { + match intent { + PublicSigningIntent::NativeTransfer(intent) => { + entry.amount = intent.amount.clone(); + entry.asset = intent.asset.clone(); + entry.chain = intent.chain.clone(); + entry.command = Some("native-transfer".to_string()); + } + PublicSigningIntent::Erc20Transfer(intent) => { + entry.amount = intent.amount.clone(); + entry.asset = intent.token.clone(); + entry.chain = intent.chain.clone(); + entry.command = Some("erc20-transfer".to_string()); + } + PublicSigningIntent::Erc20Approval(intent) => { + entry.amount = intent.amount.clone(); + entry.asset = intent.token.clone(); + entry.chain = intent.chain.clone(); + entry.command = Some("erc20-approval".to_string()); + } + PublicSigningIntent::ContractTransaction(intent) => { + entry.amount = intent.native_value.clone(); + entry.asset = "native".to_string(); + entry.chain = intent.chain.clone(); + entry.command = Some("contract-transaction".to_string()); + } + PublicSigningIntent::FetchPayment(intent) => { + entry.amount = intent.amount.clone(); + entry.asset = intent.asset.clone(); + entry.chain = intent.chain.clone(); + entry.command = Some("fetch-payment".to_string()); + } + PublicSigningIntent::AppActionPlan(intent) => { + entry.app = Some(intent.app_id.clone()); + entry.chain = intent.chain.clone(); + entry.command = Some(intent.command.clone()); + entry.plan_hash = Some(intent.plan_hash.clone()); + entry.approval_id = intent.approval_id.clone(); + } + } +} + +fn short_hash(value: String) -> String { + let digest = Sha256::digest(value.as_bytes()); + hex::encode(&digest[..8]) +} diff --git a/pkg/beam-cli/src/profiles/mod.rs b/pkg/beam-cli/src/profiles/mod.rs new file mode 100644 index 0000000..9fa7db8 --- /dev/null +++ b/pkg/beam-cli/src/profiles/mod.rs @@ -0,0 +1,11 @@ +pub mod daemon; +mod error; +pub mod ledger; +pub mod model; +pub mod policy; +pub mod session; +pub mod signer; +pub mod store; +pub mod wire; + +pub use error::{Error, Result}; diff --git a/pkg/beam-cli/src/profiles/model.rs b/pkg/beam-cli/src/profiles/model.rs new file mode 100644 index 0000000..f59592f --- /dev/null +++ b/pkg/beam-cli/src/profiles/model.rs @@ -0,0 +1,216 @@ +// lint-long-file-override allow-max-lines=220 +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfilesState { + #[serde(default)] + pub profiles: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfileRecord { + pub name: String, + pub wallet_address: String, + pub wallet_selector: String, + pub created_at: u64, + pub updated_at: u64, + pub default_ttl_secs: u64, + #[serde(default)] + pub policy: ProfilePolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub integrity: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfilePolicy { + #[serde(default)] + pub command_grants: Vec, + #[serde(default)] + pub app_grants: Vec, + #[serde(default)] + pub privacy: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommandGrant { + pub id: String, + pub command: PublicCommandKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recipient: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spender: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_native_value: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_token_amount: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_gas: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cumulative_budget: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl_secs: Option, + #[serde(default)] + pub allow_unlimited_approval: bool, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum PublicCommandKind { + NativeTransfer, + Erc20Transfer, + Erc20Approval, + ContractTransaction, + FetchPayment, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppGrant { + pub id: String, + pub app_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wallet: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chain: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan_hash: Option, + #[serde(default)] + pub bindings: Vec, + #[serde(default)] + pub constraints: Vec, + #[serde(default)] + pub steps: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_gas: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cumulative_budget: Option, + #[serde(default)] + pub allow_unlimited_approval: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionGrantBinding { + pub key: String, + pub value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ActionGrantStep { + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spender: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(default)] + pub metadata: Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct PrivacyGrant { + pub capability: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum PublicSigningIntent { + NativeTransfer(TransferIntent), + Erc20Transfer(TokenTransferIntent), + Erc20Approval(ApprovalIntent), + ContractTransaction(ContractIntent), + FetchPayment(FetchPaymentIntent), + AppActionPlan(AppActionIntent), +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct TransferIntent { + pub wallet: String, + pub chain: String, + pub recipient: String, + pub amount: String, + pub asset: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct TokenTransferIntent { + pub wallet: String, + pub chain: String, + pub token: String, + pub recipient: String, + pub amount: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ApprovalIntent { + pub wallet: String, + pub chain: String, + pub token: String, + pub spender: String, + pub amount: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ContractIntent { + pub wallet: String, + pub chain: String, + pub target: String, + pub selector: String, + pub native_value: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct FetchPaymentIntent { + pub wallet: String, + pub scheme: String, + pub origin: String, + pub chain: String, + pub asset: String, + pub recipient: String, + pub amount: String, + pub private_payment: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppActionIntent { + pub app_id: String, + pub app_version: String, + pub registry_url: Option, + pub manifest_digest: String, + pub module_digest: String, + pub command: String, + pub wallet: String, + pub chain: String, + pub plan_hash: String, + pub expires_at: u64, + #[serde(default)] + pub bindings: Vec, + #[serde(default)] + pub constraints: Vec, + #[serde(default)] + pub steps: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_id: Option, +} diff --git a/pkg/beam-cli/src/profiles/policy/command.rs b/pkg/beam-cli/src/profiles/policy/command.rs new file mode 100644 index 0000000..8046280 --- /dev/null +++ b/pkg/beam-cli/src/profiles/policy/command.rs @@ -0,0 +1,137 @@ +use contracts::U256; + +use crate::profiles::{ + ledger::LedgerState, + model::{CommandGrant, PublicCommandKind, PublicSigningIntent}, + policy::{amount_allows, budget_allows, optional_addr_eq, optional_eq, optional_selector_eq}, + wire::WireTransaction, +}; + +pub(super) fn command_grant_matches( + grant: &CommandGrant, + intent: &PublicSigningIntent, + transaction: &WireTransaction, + ledger: &LedgerState, +) -> bool { + let Some(summary) = command_summary(intent) else { + return false; + }; + grant.command == summary.command + && optional_eq(grant.chain.as_deref(), Some(summary.chain)) + && optional_eq(grant.token.as_deref(), summary.token) + && optional_addr_eq(grant.recipient.as_deref(), summary.recipient) + && optional_addr_eq(grant.target.as_deref(), summary.target) + && optional_selector_eq(grant.selector.as_deref(), summary.selector) + && optional_addr_eq(grant.spender.as_deref(), summary.spender) + && amount_allows(grant.max_native_value.as_deref(), summary.native_value) + && amount_allows(grant.max_token_amount.as_deref(), summary.token_amount) + && amount_allows(grant.max_gas.as_deref(), Some(&transaction.gas)) + && approval_allows(grant, summary.token_amount) + && budget_allows( + grant.cumulative_budget.as_deref(), + ledger, + &grant.id, + summary.asset, + summary.spend, + ) +} + +struct CommandSummary<'a> { + command: PublicCommandKind, + chain: &'a str, + token: Option<&'a str>, + recipient: Option<&'a str>, + target: Option<&'a str>, + selector: Option<&'a str>, + spender: Option<&'a str>, + native_value: Option<&'a str>, + token_amount: Option<&'a str>, + asset: &'a str, + spend: Option<&'a str>, +} + +fn command_summary(intent: &PublicSigningIntent) -> Option> { + match intent { + PublicSigningIntent::NativeTransfer(intent) => Some(CommandSummary { + command: PublicCommandKind::NativeTransfer, + chain: &intent.chain, + token: None, + recipient: Some(&intent.recipient), + target: None, + selector: None, + spender: None, + native_value: Some(&intent.amount), + token_amount: None, + asset: &intent.asset, + spend: Some(&intent.amount), + }), + PublicSigningIntent::Erc20Transfer(intent) => Some(CommandSummary { + command: PublicCommandKind::Erc20Transfer, + chain: &intent.chain, + token: Some(&intent.token), + recipient: Some(&intent.recipient), + target: Some(&intent.token), + selector: Some("0xa9059cbb"), + spender: None, + native_value: None, + token_amount: Some(&intent.amount), + asset: &intent.token, + spend: Some(&intent.amount), + }), + PublicSigningIntent::Erc20Approval(intent) => Some(CommandSummary { + command: PublicCommandKind::Erc20Approval, + chain: &intent.chain, + token: Some(&intent.token), + recipient: None, + target: Some(&intent.token), + selector: Some("0x095ea7b3"), + spender: Some(&intent.spender), + native_value: None, + token_amount: Some(&intent.amount), + asset: &intent.token, + spend: Some(&intent.amount), + }), + PublicSigningIntent::ContractTransaction(intent) => Some(CommandSummary { + command: PublicCommandKind::ContractTransaction, + chain: &intent.chain, + token: None, + recipient: None, + target: Some(&intent.target), + selector: Some(&intent.selector), + spender: None, + native_value: Some(&intent.native_value), + token_amount: None, + asset: "native", + spend: Some(&intent.native_value), + }), + PublicSigningIntent::FetchPayment(intent) => Some(CommandSummary { + command: PublicCommandKind::FetchPayment, + chain: &intent.chain, + token: Some(&intent.asset), + recipient: Some(&intent.recipient), + target: None, + selector: None, + spender: None, + native_value: None, + token_amount: Some(&intent.amount), + asset: &intent.asset, + spend: Some(&intent.amount), + }), + PublicSigningIntent::AppActionPlan(_) => None, + } +} + +fn approval_allows(grant: &CommandGrant, amount: Option<&str>) -> bool { + if grant.command != PublicCommandKind::Erc20Approval { + return true; + } + grant.allow_unlimited_approval + || amount.is_none_or(|amount| parse_u256(amount).is_ok_and(|value| value != U256::MAX)) +} + +fn parse_u256(value: &str) -> Result { + if let Some(value) = value.strip_prefix("0x") { + return U256::from_str_radix(value, 16).map_err(|_| ()); + } + U256::from_dec_str(value).map_err(|_| ()) +} diff --git a/pkg/beam-cli/src/profiles/policy/mod.rs b/pkg/beam-cli/src/profiles/policy/mod.rs new file mode 100644 index 0000000..d931b78 --- /dev/null +++ b/pkg/beam-cli/src/profiles/policy/mod.rs @@ -0,0 +1,265 @@ +// lint-long-file-override allow-max-lines=270 +mod command; + +use contracts::U256; + +use crate::profiles::{ + Error, Result, + ledger::{LedgerState, spent_for_grant}, + model::{AppActionIntent, AppGrant, ProfileRecord, PublicSigningIntent}, + store::now, + wire::WireTransaction, +}; + +use command::command_grant_matches; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PolicyDecision { + pub grant_id: String, +} + +pub fn authorize( + profile: &ProfileRecord, + ledger: &LedgerState, + intent: &PublicSigningIntent, + transaction: &WireTransaction, +) -> Result { + ensure_wallet(profile, intent)?; + ensure_final_transaction(intent, transaction)?; + if !profile.policy.privacy.is_empty() { + let capability = profile.policy.privacy[0].capability.clone(); + return Err(Error::PrivacyCapabilityUnsupported { capability }); + } + + match intent { + PublicSigningIntent::AppActionPlan(intent) => profile + .policy + .app_grants + .iter() + .find(|grant| app_grant_matches(grant, intent, transaction, ledger)) + .map(|grant| PolicyDecision { + grant_id: grant.id.clone(), + }) + .ok_or_else(|| Error::PolicyDenied { + reason: "no app grant matched final action plan".to_string(), + }), + _ => profile + .policy + .command_grants + .iter() + .find(|grant| command_grant_matches(grant, intent, transaction, ledger)) + .map(|grant| PolicyDecision { + grant_id: grant.id.clone(), + }) + .ok_or_else(|| Error::PolicyDenied { + reason: "no command grant matched final transaction".to_string(), + }), + } +} + +fn ensure_wallet(profile: &ProfileRecord, intent: &PublicSigningIntent) -> Result<()> { + let wallet = match intent { + PublicSigningIntent::NativeTransfer(intent) => &intent.wallet, + PublicSigningIntent::Erc20Transfer(intent) => &intent.wallet, + PublicSigningIntent::Erc20Approval(intent) => &intent.wallet, + PublicSigningIntent::ContractTransaction(intent) => &intent.wallet, + PublicSigningIntent::FetchPayment(intent) => &intent.wallet, + PublicSigningIntent::AppActionPlan(intent) => &intent.wallet, + }; + if wallet.eq_ignore_ascii_case(&profile.wallet_address) { + Ok(()) + } else { + Err(Error::PolicyDenied { + reason: "wallet mismatch".to_string(), + }) + } +} + +fn ensure_final_transaction( + intent: &PublicSigningIntent, + transaction: &WireTransaction, +) -> Result<()> { + let to = transaction.to.as_deref().unwrap_or_default(); + let value = parse_u256(&transaction.value)?; + match intent { + PublicSigningIntent::NativeTransfer(intent) => { + ensure_eq_addr(to, &intent.recipient, "recipient")?; + ensure_eq_amount(value, &intent.amount, "native value")?; + ensure_empty_data(transaction)?; + } + PublicSigningIntent::Erc20Transfer(intent) => { + ensure_eq_addr(to, &intent.token, "token")?; + ensure_selector(transaction, "0xa9059cbb")?; + ensure_eq_amount(value, "0", "native value")?; + } + PublicSigningIntent::Erc20Approval(intent) => { + ensure_eq_addr(to, &intent.token, "token")?; + ensure_selector(transaction, "0x095ea7b3")?; + ensure_eq_amount(value, "0", "native value")?; + } + PublicSigningIntent::ContractTransaction(intent) => { + ensure_eq_addr(to, &intent.target, "target")?; + ensure_selector(transaction, &intent.selector)?; + ensure_eq_amount(value, &intent.native_value, "native value")?; + } + PublicSigningIntent::FetchPayment(intent) => { + if intent.private_payment { + return Err(Error::PrivacyCapabilityUnsupported { + capability: "private-payment".to_string(), + }); + } + } + PublicSigningIntent::AppActionPlan(intent) => { + if intent.expires_at < now() { + return Err(Error::PolicyDenied { + reason: "app action plan expired".to_string(), + }); + } + } + } + Ok(()) +} + +fn app_grant_matches( + grant: &AppGrant, + intent: &AppActionIntent, + transaction: &WireTransaction, + ledger: &LedgerState, +) -> bool { + grant.app_id == intent.app_id + && optional_eq( + grant.registry_url.as_deref(), + intent.registry_url.as_deref(), + ) + && optional_eq(grant.app_version.as_deref(), Some(&intent.app_version)) + && optional_eq( + grant.manifest_digest.as_deref(), + Some(&intent.manifest_digest), + ) + && optional_eq(grant.module_digest.as_deref(), Some(&intent.module_digest)) + && optional_eq(grant.command.as_deref(), Some(&intent.command)) + && optional_addr_eq(grant.wallet.as_deref(), Some(&intent.wallet)) + && optional_eq(grant.chain.as_deref(), Some(&intent.chain)) + && optional_eq(grant.plan_hash.as_deref(), Some(&intent.plan_hash)) + && grant.expires_at.is_none_or(|expiry| expiry >= now()) + && grant + .bindings + .iter() + .all(|binding| intent.bindings.iter().any(|candidate| candidate == binding)) + && grant + .constraints + .iter() + .all(|constraint| intent.constraints.contains(constraint)) + && (grant.steps.is_empty() || grant.steps == intent.steps) + && amount_allows(grant.max_gas.as_deref(), Some(&transaction.gas)) + && budget_allows( + grant.cumulative_budget.as_deref(), + ledger, + &grant.id, + "app", + Some(&transaction.value), + ) +} + +pub(super) fn optional_eq(expected: Option<&str>, actual: Option<&str>) -> bool { + expected.is_none_or(|expected| actual.is_some_and(|actual| actual == expected)) +} + +pub(super) fn optional_addr_eq(expected: Option<&str>, actual: Option<&str>) -> bool { + expected + .is_none_or(|expected| actual.is_some_and(|actual| expected.eq_ignore_ascii_case(actual))) +} + +pub(super) fn optional_selector_eq(expected: Option<&str>, actual: Option<&str>) -> bool { + expected.is_none_or(|expected| { + actual.is_some_and(|actual| normalize_selector(expected) == normalize_selector(actual)) + }) +} + +pub(super) fn amount_allows(limit: Option<&str>, value: Option<&str>) -> bool { + let Some(limit) = limit else { + return true; + }; + let Some(value) = value else { + return true; + }; + parse_u256(value).is_ok_and(|value| parse_u256(limit).is_ok_and(|limit| value <= limit)) +} + +pub(super) fn budget_allows( + budget: Option<&str>, + ledger: &LedgerState, + grant_id: &str, + asset: &str, + value: Option<&str>, +) -> bool { + let Some(budget) = budget else { + return true; + }; + let Some(value) = value else { + return true; + }; + let Ok(budget) = parse_u256(budget) else { + return false; + }; + let Ok(value) = parse_u256(value) else { + return false; + }; + spent_for_grant(ledger, grant_id, asset).saturating_add(value) <= budget +} + +fn ensure_eq_addr(actual: &str, expected: &str, field: &str) -> Result<()> { + if actual.eq_ignore_ascii_case(expected) { + Ok(()) + } else { + Err(Error::PolicyDenied { + reason: format!("{field} mismatch"), + }) + } +} + +fn ensure_eq_amount(actual: U256, expected: &str, field: &str) -> Result<()> { + if actual == parse_u256(expected)? { + Ok(()) + } else { + Err(Error::PolicyDenied { + reason: format!("{field} mismatch"), + }) + } +} + +fn ensure_empty_data(transaction: &WireTransaction) -> Result<()> { + if transaction.data == "0x" || transaction.data == "0x00" || transaction.data.is_empty() { + Ok(()) + } else { + Err(Error::PolicyDenied { + reason: "native transfer calldata mismatch".to_string(), + }) + } +} + +fn ensure_selector(transaction: &WireTransaction, selector: &str) -> Result<()> { + if normalize_selector(&transaction.data).starts_with(&normalize_selector(selector)) { + Ok(()) + } else { + Err(Error::PolicyDenied { + reason: "function selector mismatch".to_string(), + }) + } +} + +fn normalize_selector(value: &str) -> String { + let value = value.strip_prefix("0x").unwrap_or(value); + format!("0x{}", value.to_ascii_lowercase()) +} + +fn parse_u256(value: &str) -> Result { + if let Some(value) = value.strip_prefix("0x") { + return U256::from_str_radix(value, 16).map_err(|_| Error::InvalidAmount { + value: format!("0x{value}"), + }); + } + U256::from_dec_str(value).map_err(|_| Error::InvalidAmount { + value: value.to_string(), + }) +} diff --git a/pkg/beam-cli/src/profiles/session.rs b/pkg/beam-cli/src/profiles/session.rs new file mode 100644 index 0000000..7a345b2 --- /dev/null +++ b/pkg/beam-cli/src/profiles/session.rs @@ -0,0 +1,96 @@ +use std::path::{Path, PathBuf}; + +use contextful::ResultContextExt; +use json_store::{FileAccess, InvalidJsonBehavior, JsonStore}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::profiles::{Error, Result, store::now}; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionsState { + #[serde(default)] + pub sessions: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfileSession { + pub profile: String, + pub wallet_address: String, + pub socket: PathBuf, + pub token: String, + pub created_at: u64, + pub expires_at: u64, +} + +pub async fn load(root: &Path) -> Result> { + Ok(JsonStore::new_with_invalid_json_behavior_and_access( + root.join("profiles"), + "sessions.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .context("load beam profile sessions")?) +} + +pub async fn upsert(root: &Path, session: ProfileSession) -> Result<()> { + let store = load(root).await?; + store + .update(|state| { + state + .sessions + .retain(|candidate| candidate.profile != session.profile); + state.sessions.push(session); + }) + .await + .context("persist beam profile session")?; + Ok(()) +} + +pub async fn remove(root: &Path, profile: &str) -> Result<()> { + let store = load(root).await?; + store + .update(|state| state.sessions.retain(|session| session.profile != profile)) + .await + .context("remove beam profile session")?; + Ok(()) +} + +pub async fn active(root: &Path, profile: &str) -> Result { + let state = load(root).await?.get().await; + let session = state + .sessions + .into_iter() + .find(|session| session.profile == profile) + .ok_or_else(|| Error::SessionNotFound { + profile: profile.to_string(), + })?; + if session.expires_at <= now() { + return Err(Error::SessionExpired { + profile: profile.to_string(), + }); + } + Ok(session) +} + +pub async fn list(root: &Path) -> Result> { + Ok(load(root).await?.get().await.sessions) +} + +pub fn socket_path(root: &Path, profile: &str) -> PathBuf { + let root_digest = Sha256::digest(root.to_string_lossy().as_bytes()); + let profile_digest = Sha256::digest(profile.as_bytes()); + std::env::temp_dir().join(format!( + "beam-profile-{}-{}.sock", + hex::encode(&root_digest[..4]), + hex::encode(&profile_digest[..4]) + )) +} + +pub fn new_token() -> String { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + format!("tok_{}", hex::encode(bytes)) +} diff --git a/pkg/beam-cli/src/profiles/signer.rs b/pkg/beam-cli/src/profiles/signer.rs new file mode 100644 index 0000000..b7ffd20 --- /dev/null +++ b/pkg/beam-cli/src/profiles/signer.rs @@ -0,0 +1,170 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use contextful::ResultContextExt; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::UnixStream, + sync::Mutex, +}; +use web3::{ + Web3, + transports::Http, + types::{SignedTransaction, TransactionParameters}, +}; + +use crate::{ + error, + profiles::{ + Error, Result, + model::PublicSigningIntent, + session::ProfileSession, + wire::{DaemonRequest, DaemonResponse, WireTransaction}, + }, + signer::Signer, +}; + +#[derive(Clone)] +pub struct ProfileSigner { + address: contracts::Address, + intent: PublicSigningIntent, + last_ledger_id: Arc>>, + session: ProfileSession, +} + +impl ProfileSigner { + pub fn new(session: ProfileSession, intent: PublicSigningIntent) -> Result { + let address = + session + .wallet_address + .parse() + .map_err(|_| Error::InvalidSessionWalletAddress { + profile: session.profile.clone(), + address: session.wallet_address.clone(), + })?; + Ok(Self { + address, + intent, + last_ledger_id: Arc::new(Mutex::new(None)), + session, + }) + } +} + +#[async_trait] +impl Signer for ProfileSigner { + fn address(&self) -> contracts::Address { + self.address + } + + async fn sign_transaction( + &self, + _client: &Web3, + transaction: TransactionParameters, + ) -> error::Result { + let response = request( + &self.session, + DaemonRequest::Sign { + token: self.session.token.clone(), + intent: Box::new(self.intent.clone()), + transaction: Box::new(WireTransaction::from_parameters(&transaction)), + }, + ) + .await?; + + let (ledger_id, transaction) = match response { + DaemonResponse::Signed { + ledger_id, + transaction, + } => (ledger_id, transaction), + DaemonResponse::Error { message } => { + return Err(Error::PolicyDenied { reason: message }.into()); + } + DaemonResponse::Ok => return Err(Error::InvalidDaemonRequest.into()), + }; + *self.last_ledger_id.lock().await = Some(ledger_id); + Ok(transaction.to_signed()?) + } + + async fn rollback_last_signature(&self) -> error::Result<()> { + let Some(ledger_id) = self.last_ledger_id.lock().await.take() else { + return Ok(()); + }; + let response = request( + &self.session, + DaemonRequest::Rollback { + token: self.session.token.clone(), + ledger_id, + }, + ) + .await?; + match response { + DaemonResponse::Ok => Ok(()), + DaemonResponse::Error { message } => { + Err(Error::PolicyDenied { reason: message }.into()) + } + DaemonResponse::Signed { .. } => Err(Error::InvalidDaemonRequest.into()), + } + } +} + +pub async fn ping(session: &ProfileSession) -> Result<()> { + match request( + session, + DaemonRequest::Ping { + token: session.token.clone(), + }, + ) + .await? + { + DaemonResponse::Ok => Ok(()), + DaemonResponse::Error { message } => Err(Error::PolicyDenied { reason: message }), + DaemonResponse::Signed { .. } => Err(Error::InvalidDaemonRequest), + } +} + +pub async fn lock(session: &ProfileSession) -> Result<()> { + match request( + session, + DaemonRequest::Lock { + token: session.token.clone(), + }, + ) + .await? + { + DaemonResponse::Ok => Ok(()), + DaemonResponse::Error { message } => Err(Error::PolicyDenied { reason: message }), + DaemonResponse::Signed { .. } => Err(Error::InvalidDaemonRequest), + } +} + +async fn request(session: &ProfileSession, request: DaemonRequest) -> Result { + let mut stream = + UnixStream::connect(&session.socket) + .await + .map_err(|_| Error::DaemonConnectionFailed { + profile: session.profile.clone(), + })?; + let bytes = serde_json::to_vec(&request).context("encode profile daemon request")?; + stream + .write_all(&bytes) + .await + .context("write profile daemon request")?; + stream + .shutdown() + .await + .context("finish profile daemon request")?; + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .context("read profile daemon response")?; + let response = serde_json::from_slice::(&response) + .context("decode profile daemon response")?; + match response { + DaemonResponse::Error { message } if message == "session-token-rejected" => { + Err(Error::SessionTokenRejected) + } + other => Ok(other), + } +} diff --git a/pkg/beam-cli/src/profiles/store.rs b/pkg/beam-cli/src/profiles/store.rs new file mode 100644 index 0000000..2659d03 --- /dev/null +++ b/pkg/beam-cli/src/profiles/store.rs @@ -0,0 +1,159 @@ +use std::{ + path::Path, + time::{SystemTime, UNIX_EPOCH}, +}; + +use contextful::ResultContextExt; +use json_store::{FileAccess, InvalidJsonBehavior, JsonStore}; +use sha2::{Digest, Sha256}; + +use crate::profiles::{ + Error, Result, + model::{ProfileRecord, ProfilesState}, +}; + +pub async fn load(root: &Path) -> Result> { + Ok(JsonStore::new_with_invalid_json_behavior_and_access( + root.join("profiles"), + "profiles.json", + InvalidJsonBehavior::Error, + FileAccess::OwnerOnly, + ) + .await + .context("load beam profiles")?) +} + +pub async fn list(root: &Path) -> Result> { + Ok(load(root).await?.get().await.profiles) +} + +pub async fn find(root: &Path, profile: &str) -> Result { + find_in_state(&load(root).await?.get().await, profile) +} + +pub fn find_in_state(state: &ProfilesState, profile: &str) -> Result { + state + .profiles + .iter() + .find(|candidate| candidate.name == profile) + .cloned() + .ok_or_else(|| Error::ProfileNotFound { + profile: profile.to_string(), + }) +} + +pub async fn insert(root: &Path, mut profile: ProfileRecord, key: &[u8]) -> Result<()> { + let store = load(root).await?; + let mut state = store.get().await; + if state + .profiles + .iter() + .any(|candidate| candidate.name == profile.name) + { + return Err(Error::ProfileAlreadyExists { + profile: profile.name, + }); + } + sign_profile(&mut profile, key)?; + state.profiles.push(profile); + store.set(state).await.context("persist beam profile")?; + Ok(()) +} + +pub async fn update_verified(root: &Path, profile: &str, key: &[u8], update: F) -> Result<()> +where + F: FnOnce(&mut ProfileRecord) -> Result<()>, +{ + let store = load(root).await?; + let mut state = store.get().await; + let record = state + .profiles + .iter_mut() + .find(|candidate| candidate.name == profile) + .ok_or_else(|| Error::ProfileNotFound { + profile: profile.to_string(), + })?; + verify_profile(record, key)?; + update(record)?; + record.updated_at = now(); + sign_profile(record, key)?; + store.set(state).await.context("persist beam profile")?; + Ok(()) +} + +pub async fn remove_verified(root: &Path, profile: &str, key: &[u8]) -> Result<()> { + let store = load(root).await?; + let mut state = store.get().await; + let record = find_in_state(&state, profile)?; + verify_profile(&record, key)?; + state.profiles.retain(|candidate| candidate.name != profile); + store + .set(state) + .await + .context("persist beam profile removal")?; + Ok(()) +} + +pub fn verify_profile(profile: &ProfileRecord, key: &[u8]) -> Result<()> { + let expected = profile_digest(profile, key)?; + match profile.integrity.as_deref() { + Some(actual) if actual == expected => Ok(()), + _ => Err(Error::ProfileIntegrityFailed { + profile: profile.name.clone(), + }), + } +} + +pub fn sign_profile(profile: &mut ProfileRecord, key: &[u8]) -> Result<()> { + profile.integrity = None; + profile.integrity = Some(profile_digest(profile, key)?); + Ok(()) +} + +pub fn integrity_key(secret_key: &[u8]) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(b"beam-cli/profile-integrity/v1"); + hasher.update(secret_key); + hasher.finalize().to_vec() +} + +pub fn validate_profile_name(profile: &str) -> Result<()> { + if profile.trim().is_empty() { + Err(Error::ProfileNameBlank) + } else { + Ok(()) + } +} + +pub fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +pub fn parse_duration_secs(value: &str) -> Result { + let (number, multiplier) = match value.chars().last() { + Some('s') => (&value[..value.len() - 1], 1), + Some('m') => (&value[..value.len() - 1], 60), + Some('h') => (&value[..value.len() - 1], 60 * 60), + Some('d') => (&value[..value.len() - 1], 24 * 60 * 60), + _ => (value, 1), + }; + number + .parse::() + .map(|seconds| seconds.saturating_mul(multiplier)) + .map_err(|_| Error::InvalidDuration { + value: value.to_string(), + }) +} + +fn profile_digest(profile: &ProfileRecord, key: &[u8]) -> Result { + let mut clone = profile.clone(); + clone.integrity = None; + let bytes = serde_json::to_vec(&clone).context("encode profile integrity payload")?; + let mut hasher = Sha256::new(); + hasher.update(key); + hasher.update(bytes); + Ok(format!("sha256:{}", hex::encode(hasher.finalize()))) +} diff --git a/pkg/beam-cli/src/profiles/wire.rs b/pkg/beam-cli/src/profiles/wire.rs new file mode 100644 index 0000000..f7d7220 --- /dev/null +++ b/pkg/beam-cli/src/profiles/wire.rs @@ -0,0 +1,158 @@ +use serde::{Deserialize, Serialize}; +use web3::types::{Bytes, H256, SignedTransaction, TransactionParameters, U64}; + +use crate::profiles::{Error, Result, model::PublicSigningIntent}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum DaemonRequest { + Ping { + token: String, + }, + Lock { + token: String, + }, + Rollback { + token: String, + ledger_id: String, + }, + Sign { + token: String, + intent: Box, + transaction: Box, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum DaemonResponse { + Ok, + Signed { + ledger_id: String, + transaction: WireSignedTransaction, + }, + Error { + message: String, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct WireTransaction { + pub nonce: Option, + pub to: Option, + pub gas: String, + pub gas_price: Option, + pub value: String, + pub data: String, + pub chain_id: Option, + pub transaction_type: Option, + pub max_fee_per_gas: Option, + pub max_priority_fee_per_gas: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct WireSignedTransaction { + pub message_hash: String, + pub v: u64, + pub r: String, + pub s: String, + pub raw_transaction: String, + pub transaction_hash: String, +} + +impl WireTransaction { + pub fn from_parameters(transaction: &TransactionParameters) -> Self { + Self { + nonce: transaction.nonce.map(|value| value.to_string()), + to: transaction.to.map(|value| format!("{value:#x}")), + gas: transaction.gas.to_string(), + gas_price: transaction.gas_price.map(|value| value.to_string()), + value: transaction.value.to_string(), + data: format!("0x{}", hex::encode(&transaction.data.0)), + chain_id: transaction.chain_id, + transaction_type: transaction.transaction_type.map(|value| value.as_u64()), + max_fee_per_gas: transaction.max_fee_per_gas.map(|value| value.to_string()), + max_priority_fee_per_gas: transaction + .max_priority_fee_per_gas + .map(|value| value.to_string()), + } + } + + pub fn to_parameters(&self) -> Result { + Ok(TransactionParameters { + nonce: parse_optional_u256(self.nonce.as_deref())?, + to: match self.to.as_deref() { + Some(value) => Some(parse_address(value)?), + None => None, + }, + gas: parse_u256(&self.gas)?, + gas_price: parse_optional_u256(self.gas_price.as_deref())?, + value: parse_u256(&self.value)?, + data: Bytes(parse_hex_data(&self.data)?), + chain_id: self.chain_id, + transaction_type: self.transaction_type.map(U64::from), + max_fee_per_gas: parse_optional_u256(self.max_fee_per_gas.as_deref())?, + max_priority_fee_per_gas: parse_optional_u256( + self.max_priority_fee_per_gas.as_deref(), + )?, + ..Default::default() + }) + } +} + +impl WireSignedTransaction { + pub fn from_signed(transaction: &SignedTransaction) -> Self { + Self { + message_hash: format!("{:#x}", transaction.message_hash), + v: transaction.v, + r: format!("{:#x}", transaction.r), + s: format!("{:#x}", transaction.s), + raw_transaction: format!("0x{}", hex::encode(&transaction.raw_transaction.0)), + transaction_hash: format!("{:#x}", transaction.transaction_hash), + } + } + + pub fn to_signed(&self) -> Result { + Ok(SignedTransaction { + message_hash: parse_h256(&self.message_hash)?, + v: self.v, + r: parse_h256(&self.r)?, + s: parse_h256(&self.s)?, + raw_transaction: Bytes(parse_hex_data(&self.raw_transaction)?), + transaction_hash: parse_h256(&self.transaction_hash)?, + }) + } +} + +fn parse_optional_u256(value: Option<&str>) -> Result> { + value.map(parse_u256).transpose() +} + +fn parse_u256(value: &str) -> Result { + if let Some(value) = value.strip_prefix("0x") { + return contracts::U256::from_str_radix(value, 16).map_err(|_| Error::InvalidAmount { + value: format!("0x{value}"), + }); + } + contracts::U256::from_dec_str(value).map_err(|_| Error::InvalidAmount { + value: value.to_string(), + }) +} + +fn parse_address(value: &str) -> Result { + value.parse().map_err(|_| Error::PolicyDenied { + reason: format!("invalid transaction address {value}"), + }) +} + +fn parse_h256(value: &str) -> Result { + value.parse().map_err(|_| Error::PolicyDenied { + reason: format!("invalid signed transaction hash {value}"), + }) +} + +fn parse_hex_data(value: &str) -> Result> { + hex::decode(value.strip_prefix("0x").unwrap_or(value)).map_err(|_| Error::PolicyDenied { + reason: "invalid transaction data".to_string(), + }) +} diff --git a/pkg/beam-cli/src/runtime.rs b/pkg/beam-cli/src/runtime.rs index 3f445e3..57d232f 100644 --- a/pkg/beam-cli/src/runtime.rs +++ b/pkg/beam-cli/src/runtime.rs @@ -28,6 +28,7 @@ use crate::{ pub struct InvocationOverrides { pub chain: Option, pub from: Option, + pub profile: Option, pub rpc: Option, } diff --git a/pkg/beam-cli/src/signer.rs b/pkg/beam-cli/src/signer.rs index 5fd086f..3675c5d 100644 --- a/pkg/beam-cli/src/signer.rs +++ b/pkg/beam-cli/src/signer.rs @@ -19,6 +19,10 @@ pub trait Signer: Send + Sync { client: &Web3, transaction: TransactionParameters, ) -> Result; + + async fn rollback_last_signature(&self) -> Result<()> { + Ok(()) + } } #[derive(Clone)] diff --git a/pkg/beam-cli/src/tests.rs b/pkg/beam-cli/src/tests.rs index 000ece2..8915f30 100644 --- a/pkg/beam-cli/src/tests.rs +++ b/pkg/beam-cli/src/tests.rs @@ -11,6 +11,7 @@ mod cli_fetch; mod cli_gas; mod cli_metadata; mod cli_privacy; +mod cli_wallet_password; mod cli_wallet_recovery; mod config; mod display; @@ -43,6 +44,7 @@ mod interactive_history; mod interactive_interrupts; mod interactive_state; mod interactive_tokens; +mod interactive_wallet_password; mod interactive_wallet_private_key; mod interactive_wallet_recovery; mod interactive_wallet_selector; @@ -52,6 +54,7 @@ mod management; mod output; mod payy_native_token; mod privacy; +mod profiles; mod prompts; mod recovery_phrase; mod rpc_validation; @@ -70,5 +73,6 @@ mod update_restart; mod util; mod wallet; mod wallet_integrity; +mod wallet_password; mod wallet_private_key; mod wallet_recovery; diff --git a/pkg/beam-cli/src/tests/cli_wallet_password.rs b/pkg/beam-cli/src/tests/cli_wallet_password.rs new file mode 100644 index 0000000..e3d2f3c --- /dev/null +++ b/pkg/beam-cli/src/tests/cli_wallet_password.rs @@ -0,0 +1,16 @@ +use clap::Parser; + +use crate::cli::{Cli, Command, WalletAction}; + +#[test] +fn parses_wallet_change_password_subcommand() { + let cli = Cli::try_parse_from(["beam", "wallets", "change-password", "alice"]) + .expect("parse wallet password change"); + + assert!(matches!( + cli.command, + Some(Command::Wallet { + action: WalletAction::ChangePassword { wallet } + }) if wallet.as_deref() == Some("alice") + )); +} diff --git a/pkg/beam-cli/src/tests/interactive_wallet_password.rs b/pkg/beam-cli/src/tests/interactive_wallet_password.rs new file mode 100644 index 0000000..c5da971 --- /dev/null +++ b/pkg/beam-cli/src/tests/interactive_wallet_password.rs @@ -0,0 +1,20 @@ +use crate::commands::{interactive::repl_command_args, interactive_helper::completion_candidates}; + +#[test] +fn change_password_wallet_command_is_parsed_as_cli_subcommand_in_repl() { + assert_eq!( + repl_command_args("wallets change-password alice").expect("parse password change"), + None + ); +} + +#[test] +fn change_password_wallet_command_is_completion_candidate() { + let wallet = completion_candidates("wallets ", "wallets ".len()); + + assert!( + wallet + .iter() + .any(|candidate| candidate == "change-password") + ); +} diff --git a/pkg/beam-cli/src/tests/profiles.rs b/pkg/beam-cli/src/tests/profiles.rs new file mode 100644 index 0000000..5778ec7 --- /dev/null +++ b/pkg/beam-cli/src/tests/profiles.rs @@ -0,0 +1,288 @@ +// lint-long-file-override allow-max-lines=290 +use clap::Parser; +use tempfile::TempDir; + +use crate::{ + cli::{Cli, Command, ProfileCommandKind, ProfileGrantKind, ProfilesAction}, + profiles::{ + Error as ProfileError, ledger, + model::{ + AppActionIntent, AppGrant, CommandGrant, ProfilePolicy, ProfileRecord, + PublicCommandKind, PublicSigningIntent, TransferIntent, + }, + policy, + session::{self, ProfileSession}, + store, + wire::WireTransaction, + }, +}; + +const ADDRESS: &str = "0x90f8bf6a479f320ead074411a4b0e7944ea8c9c1"; +const RECIPIENT: &str = "0xffcf8fdee72ac11b5c542428b35eef5769c409f0"; + +#[test] +fn parses_profiles_cli_surface() { + let cli = Cli::try_parse_from([ + "beam", + "--profile", + "agent", + "profiles", + "grant", + "agent", + "command", + "native-transfer", + "--chain", + "base", + "--recipient", + RECIPIENT, + "--max-native", + "100", + ]) + .expect("parse profile grant"); + + assert_eq!(cli.overrides().profile.as_deref(), Some("agent")); + assert!(matches!( + cli.command, + Some(Command::Profiles { + action: ProfilesAction::Grant(args) + }) if matches!( + &args.grant, + ProfileGrantKind::Command(command) + if command.command == ProfileCommandKind::NativeTransfer + && command.chain.as_deref() == Some("base") + ) + )); +} + +#[test] +fn profile_integrity_detects_tampering() { + let key = b"test-integrity-key"; + let mut profile = profile_record(); + store::sign_profile(&mut profile, key).expect("sign profile"); + store::verify_profile(&profile, key).expect("verify profile"); + + profile.wallet_address = RECIPIENT.to_string(); + let err = store::verify_profile(&profile, key).expect_err("reject tampered profile"); + + assert!(matches!(err, ProfileError::ProfileIntegrityFailed { profile } if profile == "agent")); +} + +#[test] +fn ledger_integrity_detects_tampering() { + let key = b"test-integrity-key"; + let mut state = ledger::LedgerState { + entries: vec![ledger::LedgerEntry { + id: "led_test".to_string(), + profile: "agent".to_string(), + grant_id: "cmd_test".to_string(), + created_at: 1, + status: ledger::LedgerStatus::Signed, + amount: "10".to_string(), + asset: "native".to_string(), + chain: "base".to_string(), + gas: "21000".to_string(), + app: None, + command: Some("native-transfer".to_string()), + plan_hash: None, + approval_id: None, + tx_hash: Some("0x01".to_string()), + }], + integrity: None, + }; + ledger::sign_state(&mut state, key).expect("sign ledger"); + ledger::verify_state(&state, key).expect("verify ledger"); + + state.entries[0].amount = "11".to_string(); + + assert!(matches!( + ledger::verify_state(&state, key), + Err(ProfileError::LedgerIntegrityFailed) + )); +} + +#[test] +fn policy_allows_matching_native_transfer_and_denies_budget_overflow() { + let mut profile = profile_record(); + profile.policy.command_grants.push(CommandGrant { + id: "cmd_native".to_string(), + command: PublicCommandKind::NativeTransfer, + chain: Some("base".to_string()), + token: None, + recipient: Some(RECIPIENT.to_string()), + target: None, + selector: None, + spender: None, + max_native_value: Some("100".to_string()), + max_token_amount: None, + max_gas: Some("30000".to_string()), + cumulative_budget: Some("100".to_string()), + ttl_secs: None, + allow_unlimited_approval: false, + }); + let intent = PublicSigningIntent::NativeTransfer(TransferIntent { + wallet: ADDRESS.to_string(), + chain: "base".to_string(), + recipient: RECIPIENT.to_string(), + amount: "50".to_string(), + asset: "native".to_string(), + }); + let tx = native_tx("50"); + let ledger = ledger::LedgerState::default(); + + let decision = policy::authorize(&profile, &ledger, &intent, &tx).expect("allow transfer"); + assert_eq!(decision.grant_id, "cmd_native"); + + let mut spent = ledger::LedgerState::default(); + spent.entries.push(ledger::LedgerEntry { + id: "led_existing".to_string(), + profile: "agent".to_string(), + grant_id: "cmd_native".to_string(), + created_at: 1, + status: ledger::LedgerStatus::Signed, + amount: "60".to_string(), + asset: "native".to_string(), + chain: "base".to_string(), + gas: "21000".to_string(), + app: None, + command: Some("native-transfer".to_string()), + plan_hash: None, + approval_id: None, + tx_hash: None, + }); + assert_eq!( + ledger::spent_for_grant(&spent, "cmd_native", "native").to_string(), + "60" + ); + + assert!(matches!( + policy::authorize(&profile, &spent, &intent, &tx), + Err(ProfileError::PolicyDenied { .. }) + )); +} + +#[test] +fn policy_matches_app_plan_and_rejects_expired_plan() { + let mut profile = profile_record(); + profile.policy.app_grants.push(AppGrant { + id: "app_swap".to_string(), + app_id: "uniswap".to_string(), + registry_url: None, + app_version: Some("1.0.0".to_string()), + manifest_digest: Some("sha256:manifest".to_string()), + module_digest: Some("sha256:wasm".to_string()), + command: Some("swap".to_string()), + wallet: Some(ADDRESS.to_string()), + chain: Some("base".to_string()), + plan_hash: Some("sha256:plan".to_string()), + bindings: Vec::new(), + constraints: Vec::new(), + steps: Vec::new(), + expires_at: None, + max_gas: Some("100000".to_string()), + cumulative_budget: None, + allow_unlimited_approval: false, + }); + let tx = native_tx("0"); + let intent = PublicSigningIntent::AppActionPlan(AppActionIntent { + app_id: "uniswap".to_string(), + app_version: "1.0.0".to_string(), + registry_url: None, + manifest_digest: "sha256:manifest".to_string(), + module_digest: "sha256:wasm".to_string(), + command: "swap".to_string(), + wallet: ADDRESS.to_string(), + chain: "base".to_string(), + plan_hash: "sha256:plan".to_string(), + expires_at: store::now() + 60, + bindings: Vec::new(), + constraints: Vec::new(), + steps: Vec::new(), + approval_id: Some("apr_test".to_string()), + }); + + policy::authorize(&profile, &ledger::LedgerState::default(), &intent, &tx) + .expect("allow app plan"); + + let PublicSigningIntent::AppActionPlan(mut expired) = intent else { + panic!("expected app intent"); + }; + expired.expires_at = 1; + + assert!(matches!( + policy::authorize( + &profile, + &ledger::LedgerState::default(), + &PublicSigningIntent::AppActionPlan(expired), + &tx, + ), + Err(ProfileError::PolicyDenied { .. }) + )); +} + +#[tokio::test] +async fn session_active_rejects_expired_sessions() { + let temp = TempDir::new().expect("create temp dir"); + session::upsert( + temp.path(), + ProfileSession { + profile: "agent".to_string(), + wallet_address: ADDRESS.to_string(), + socket: temp.path().join("agent.sock"), + token: "tok_test".to_string(), + created_at: 1, + expires_at: 1, + }, + ) + .await + .expect("persist session"); + + assert!(matches!( + session::active(temp.path(), "agent").await, + Err(ProfileError::SessionExpired { profile }) if profile == "agent" + )); +} + +#[test] +fn profile_socket_path_stays_short_for_long_roots() { + let temp = TempDir::new().expect("create temp dir"); + let long_root = temp.path().join("a".repeat(160)); + let socket = session::socket_path(&long_root, "agent"); + + assert!(!socket.starts_with(&long_root)); + assert!( + socket + .file_name() + .expect("socket filename") + .to_string_lossy() + .len() + < 80 + ); +} + +fn profile_record() -> ProfileRecord { + ProfileRecord { + name: "agent".to_string(), + wallet_address: ADDRESS.to_string(), + wallet_selector: "alice".to_string(), + created_at: 1, + updated_at: 1, + default_ttl_secs: 3600, + policy: ProfilePolicy::default(), + integrity: None, + } +} + +fn native_tx(value: &str) -> WireTransaction { + WireTransaction { + nonce: Some("0".to_string()), + to: Some(RECIPIENT.to_string()), + gas: "21000".to_string(), + gas_price: Some("1".to_string()), + value: value.to_string(), + data: "0x".to_string(), + chain_id: Some(8453), + transaction_type: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + } +} diff --git a/pkg/beam-cli/src/tests/update.rs b/pkg/beam-cli/src/tests/update.rs index 667a98b..63c7a06 100644 --- a/pkg/beam-cli/src/tests/update.rs +++ b/pkg/beam-cli/src/tests/update.rs @@ -153,14 +153,14 @@ async fn available_update_scans_past_non_beam_release_pages() { ]); let page_2 = json!([ { - "tag_name": "beam-v0.3.0", + "tag_name": "beam-v1000.0.0", "draft": false, "prerelease": false, "assets": [ { "name": asset_name, "digest": format!("sha256:{}", "a".repeat(64)), - "browser_download_url": "https://example.invalid/beam-v0.3.0" + "browser_download_url": "https://example.invalid/beam-v1000.0.0" } ] } @@ -199,7 +199,7 @@ async fn available_update_scans_past_non_beam_release_pages() { .await .expect("load update info"); - assert_eq!(update.expect("beam release").tag_name, "beam-v0.3.0"); + assert_eq!(update.expect("beam release").tag_name, "beam-v1000.0.0"); } #[tokio::test] @@ -228,6 +228,7 @@ async fn run_cli_update_skips_corrupted_local_state_bootstrap() { rpc: None, from: None, chain: None, + profile: None, output: OutputMode::Quiet, color: ColorMode::Never, no_update_check: false, diff --git a/pkg/beam-cli/src/tests/update_restart.rs b/pkg/beam-cli/src/tests/update_restart.rs index b9a7c81..2fc7dbe 100644 --- a/pkg/beam-cli/src/tests/update_restart.rs +++ b/pkg/beam-cli/src/tests/update_restart.rs @@ -11,6 +11,7 @@ fn invocation_overrides(chain: Option<&str>, from: Option<&str>) -> InvocationOv InvocationOverrides { chain: chain.map(ToOwned::to_owned), from: from.map(ToOwned::to_owned), + profile: None, rpc: None, } } diff --git a/pkg/beam-cli/src/tests/wallet_password.rs b/pkg/beam-cli/src/tests/wallet_password.rs new file mode 100644 index 0000000..bb1a7dc --- /dev/null +++ b/pkg/beam-cli/src/tests/wallet_password.rs @@ -0,0 +1,90 @@ +use super::fixtures::test_app_with_output; +use crate::{ + commands::wallet_password::change_wallet_password_with_passwords, + error::Error, + keystore::{KeyStore, StoredWallet, decrypt_private_key, encrypt_private_key, wallet_address}, + output::OutputMode, + runtime::{BeamApp, InvocationOverrides}, +}; + +const SECRET_KEY: [u8; 32] = [1u8; 32]; + +async fn seed_encrypted_wallet(app: &BeamApp, name: &str, password: &str) -> String { + let encrypted_private_key = + encrypt_private_key(&SECRET_KEY, password).expect("encrypt private key"); + let address = format!( + "{:#x}", + wallet_address(&SECRET_KEY).expect("derive wallet address") + ); + + app.keystore_store + .set(KeyStore { + wallets: vec![StoredWallet { + address: address.clone(), + encrypted_key: encrypted_private_key.encrypted_key, + name: name.to_string(), + salt: encrypted_private_key.salt, + kdf: encrypted_private_key.kdf, + }], + }) + .await + .expect("persist keystore"); + + app.config_store + .update(|config| config.default_wallet = Some(name.to_string())) + .await + .expect("persist default wallet"); + + address +} + +#[tokio::test] +async fn change_password_reencrypts_empty_password_wallet() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + let address = seed_encrypted_wallet(&app, "alice", "").await; + + let output = change_wallet_password_with_passwords(&app, None, "", "beam-password") + .await + .expect("change wallet password"); + + assert_eq!(output.value["address"], address); + let keystore = app.keystore_store.get().await; + let wallet = &keystore.wallets[0]; + assert!(matches!( + decrypt_private_key(wallet, ""), + Err(Error::DecryptionFailed) + )); + assert_eq!( + decrypt_private_key(wallet, "beam-password").expect("decrypt with new password"), + SECRET_KEY.to_vec() + ); +} + +#[tokio::test] +async fn change_password_rejects_wrong_current_password_without_mutating_wallet() { + let (_temp_dir, app) = + test_app_with_output(OutputMode::Quiet, InvocationOverrides::default()).await; + seed_encrypted_wallet(&app, "alice", "old-password").await; + + let err = change_wallet_password_with_passwords( + &app, + Some("alice"), + "wrong-password", + "new-password", + ) + .await + .expect_err("reject wrong current password"); + assert!(matches!(err, Error::DecryptionFailed)); + + let keystore = app.keystore_store.get().await; + let wallet = &keystore.wallets[0]; + assert_eq!( + decrypt_private_key(wallet, "old-password").expect("decrypt with old password"), + SECRET_KEY.to_vec() + ); + assert!(matches!( + decrypt_private_key(wallet, "new-password"), + Err(Error::DecryptionFailed) + )); +} diff --git a/pkg/beam-cli/src/transaction.rs b/pkg/beam-cli/src/transaction.rs index dd3aaa1..c38d98f 100644 --- a/pkg/beam-cli/src/transaction.rs +++ b/pkg/beam-cli/src/transaction.rs @@ -3,7 +3,7 @@ use std::{ time::{Duration, Instant}, }; -use contextful::ResultContextExt; +use contextful::{ErrorContextExt, ResultContextExt}; use contracts::Client; use tokio::time::{MissedTickBehavior, interval}; use web3::types::{H256, TransactionParameters}; @@ -70,7 +70,7 @@ pub async fn submit_and_wait( cancel: C, ) -> Result where - S: Signer, + S: Signer + ?Sized, F: FnMut(TransactionStatusUpdate), C: Future, { @@ -78,10 +78,10 @@ where .sign_transaction(client.client(), transaction) .await?; let tx_hash = format!("{:#x}", signed.transaction_hash); - client - .send_raw_transaction(signed.raw_transaction) - .await - .context("submit beam transaction")?; + if let Err(err) = client.send_raw_transaction(signed.raw_transaction).await { + signer.rollback_last_signature().await?; + return Err(err.context("submit beam transaction").into()); + } on_status(TransactionStatusUpdate::Submitted { tx_hash: tx_hash.clone(), diff --git a/scripts/copybara-sync.sh b/scripts/copybara-sync.sh index 424a73d..a7abd03 100755 --- a/scripts/copybara-sync.sh +++ b/scripts/copybara-sync.sh @@ -99,12 +99,25 @@ copybara() { java -Duser.home="$COPYBARA_AUTH_DIR" -jar "$COPYBARA_JAR" migrate "$copybara_config" "$@" } +copybara_allow_noop() { + local status + status=0 + + copybara "$@" || status=$? + if [[ "$status" == "4" ]]; then + log "Copybara reported a no-op migration; treating it as successful." + return 0 + fi + + return "$status" +} + trap cleanup EXIT rm -rf "$snapshot_dir_host" mkdir -p "$snapshot_dir_host" -copybara snapshot "$COPYBARA_SOURCE_REF" --ignore-noop --folder-dir "$snapshot_dir_host" +copybara_allow_noop snapshot "$COPYBARA_SOURCE_REF" --ignore-noop --folder-dir "$snapshot_dir_host" prepare_lockfile @@ -122,4 +135,4 @@ if [[ "$COPYBARA_INIT_HISTORY" == "1" ]]; then push_args+=(--init-history) fi -copybara "${push_args[@]}" +copybara_allow_noop "${push_args[@]}"