diff --git a/Cargo.lock b/Cargo.lock index bd52e68e..b28a8b38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1591,6 +1591,7 @@ dependencies = [ "serde_json", "serial_test", "sha2", + "siegel-uniffi", "strum", "subtle", "tar", @@ -5202,6 +5203,28 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siegel" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ad2272eb97e86ac485964f8d26287d73af4ae9e1a3ddbaf7586644aa41c0a6" +dependencies = [ + "libc", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "siegel-uniffi" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b30a1624db67abe426b85ff03348f361af58be258bad030b664340c7105fd41" +dependencies = [ + "siegel", + "thiserror 2.0.18", + "uniffi", +] + [[package]] name = "signature" version = "2.2.0" diff --git a/bedrock/Cargo.toml b/bedrock/Cargo.toml index 4e8fc0b2..4b2ebb62 100644 --- a/bedrock/Cargo.toml +++ b/bedrock/Cargo.toml @@ -86,6 +86,7 @@ x509-cert = { version = "0.2.5", features = ["pem"] } zeroize = "1.8" hex-literal = "1.1.0" # Provides the `hex!` macro for compile-time hex decoding http = "1.4.0" +siegel-uniffi = "0.1.0" # TODO: once `rand` can be bumped to 0.9, add the explicit feature flag `os_rng`. this explicitly determines no `thread_rng`; # bumping this requires crypto_box to update rand: https://github.com/RustCrypto/nacl-compat/issues/176 diff --git a/bedrock/src/lib.rs b/bedrock/src/lib.rs index feeb60e0..93e4a8d0 100644 --- a/bedrock/src/lib.rs +++ b/bedrock/src/lib.rs @@ -54,3 +54,4 @@ pub mod siwe; pub mod test_utils; uniffi::setup_scaffolding!("bedrock"); +siegel_uniffi::uniffi_reexport_scaffolding!(); diff --git a/bedrock/src/siwe/test.rs b/bedrock/src/siwe/test.rs index fd817ea6..c4eb34ca 100644 --- a/bedrock/src/siwe/test.rs +++ b/bedrock/src/siwe/test.rs @@ -21,7 +21,7 @@ const TEST_KEY: &str = const TEST_WALLET: &str = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; fn test_smart_account() -> SafeSmartAccount { - SafeSmartAccount::new(TEST_KEY.into(), TEST_WALLET).unwrap() + SafeSmartAccount::from_private_key_hex(TEST_KEY.to_string(), TEST_WALLET).unwrap() } fn make_valid_message(datetime: &str) -> String { @@ -470,7 +470,9 @@ fn parse_accepts_message_at_max_length() { fn sign_produces_verifiable_signature() { let signer = PrivateKeySigner::from_str(TEST_KEY).unwrap(); let eoa_address = signer.address(); - let account = SafeSmartAccount::new(TEST_KEY.into(), TEST_WALLET).unwrap(); + let account = + SafeSmartAccount::from_private_key_hex(TEST_KEY.to_string(), TEST_WALLET) + .unwrap(); let msg = SiweMessage { scheme: Some(Scheme::HTTPS), diff --git a/bedrock/src/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index f317d7bc..b90ab5a9 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -1,12 +1,10 @@ -use std::str::FromStr; +use std::{str::FromStr, sync::Arc}; -use alloy::{ - dyn_abi::TypedData, - primitives::Address, - signers::{k256::ecdsa::SigningKey, local::LocalSigner}, -}; +use alloy::{dyn_abi::TypedData, primitives::Address, signers::local::LocalSigner}; +use siegel_uniffi::{SessionError, SiegelSession}; pub use signer::{Eip191Signer, EoaSigner, SafeSmartAccountSigner}; pub use transaction_4337::Is4337Encodable; +use zeroize::Zeroizing; #[cfg(any(test, doc))] use crate::primitives::Network; @@ -79,6 +77,17 @@ pub enum SafeSmartAccountError { /// For security reasons, the contract is restricted from directly signing `TypedData`. #[error("the contract {0} is restricted from TypedData signing.")] RestrictedContract(String), + /// Error originating from the siegel secure-memory session backing the EOA key. + /// + /// `kind` is a stable machine-readable discriminant (e.g. `"consumed"`, + /// `"invalid_state"`); `message` is the human-readable description. + #[error("siegel session error ({kind}): {error_message}")] + SiegelSession { + /// Stable machine-readable discriminant for the underlying [`SessionError`] variant. + kind: String, + /// Full description of the session error. + error_message: String, + }, /// A provided raw input could not be parsed, is incorrectly formatted, incorrectly encoded or otherwise invalid. #[error("invalid input on {attribute}: {error_message}")] InvalidInput { @@ -98,60 +107,108 @@ impl From for SafeSmartAccountError { } } +impl std::fmt::Debug for SafeSmartAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SafeSmartAccount") + .field("eoa_address", &self.eoa_address) + .field("wallet_address", &self.wallet_address) + .finish_non_exhaustive() + } +} + +impl From for SafeSmartAccountError { + fn from(e: SessionError) -> Self { + let kind = match &e { + SessionError::InvalidLength => "invalid_length", + SessionError::LengthMismatch => "length_mismatch", + SessionError::InvalidState => "invalid_state", + SessionError::Consumed => "consumed", + SessionError::AllocationFailed { .. } => "allocation_failed", + SessionError::ProtectionFailed { .. } => "protection_failed", + SessionError::LockFailed { .. } => "lock_failed", + SessionError::CanaryCorrupted => "canary_corrupted", + }; + Self::SiegelSession { + kind: kind.to_string(), + error_message: e.to_string(), + } + } +} + /// A Safe Smart Account (previously Gnosis Safe) is the representation of a Safe smart contract. /// /// It is used to sign messages, transactions and typed data on behalf of the Safe smart contract. /// /// Reference: -#[derive(Debug, uniffi::Object)] +#[derive(uniffi::Object)] pub struct SafeSmartAccount { - /// The Ethereum signer from the EOA which is an owner for the Safe Smart Account. - signer: LocalSigner, + /// Foreign-side key store that yields a fresh siegel session per signing call. + key_manager: Arc, + /// The EOA address derived from the private key, cached at construction so that + /// the secret only has to be touched on actual signing calls. + eoa_address: Address, /// The address of the Safe Smart Account (i.e. the deployed smart contract) pub wallet_address: Address, } #[bedrock_export] impl SafeSmartAccount { - /// Initializes a new `SafeSmartAccount` instance with the given EOA signing key. + /// Initializes a new `SafeSmartAccount` instance, deriving the EOA address + /// from the private key obtained through the foreign-side key manager. + /// + /// The key is read exactly once at construction so the [`SiegelSession`] + /// can be zeroized immediately. Subsequent signing calls request a fresh + /// session from the key manager. /// /// # Arguments - /// - `private_key`: A hex-encoded string representing the **secret key** of the EOA who is an owner in the Safe. + /// - `key_manager`: Foreign-side [`SmartAccountKeyManager`] that delivers the EOA private key in a one-shot + /// siegel-protected session. /// - `wallet_address`: The address of the Safe Smart Account (i.e. the deployed smart contract). This is required because /// some legacy versions of the wallet were computed differently. Today, it cannot be deterministically computed for all /// users. This is also necessary to support signing for Safes deployed by third-party Mini App devs, where the /// wallet address is only known at runtime. /// /// # Errors + /// - Will return an error if the wallet address is not a valid hex-encoded address. /// - Will return an error if the key is not a validly encoded hex string. - /// - Will return an error if the key is not a valid point in the k256 curve. + /// - Will return an error if the decoded key is not a valid k256 scalar (zero or out of range). + /// - Will return an error if the siegel session cannot be read. #[uniffi::constructor] pub fn new( - private_key: String, + key_manager: Arc, wallet_address: &str, ) -> Result { - debug!( - "Initializing SafeSmartAccount with wallet address: {}", - wallet_address - ); - - let signer = LocalSigner::from_slice( - &hex::decode(private_key) - .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?, - ) - .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?; - let wallet_address = Address::from_str(wallet_address).map_err(|_| { SafeSmartAccountError::AddressParsing(wallet_address.to_string()) })?; + // Read the key once to validate it and derive the EOA address. + // The session is consumed and zeroized inside `read_once`. + let siegel = key_manager.get_eoa_private_key(); + let eoa_address = siegel.read_once( + |private_key| -> Result { + let raw_key: Zeroizing> = + Zeroizing::new(hex::decode(private_key).map_err(|e| { + SafeSmartAccountError::KeyDecoding(e.to_string()) + })?); + let signer = LocalSigner::from_slice(&raw_key) + .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?; + let address = signer.address(); + // Redundant (`raw_key` and `signer` get immediately zeroized) but added for an abundance of clarity + drop(raw_key); + drop(signer); + Ok(address) + }, + )??; + debug!( "Successfully initialized SafeSmartAccount for wallet: {}", wallet_address ); Ok(Self { - signer, + key_manager, + eoa_address, wallet_address, }) } @@ -206,40 +263,45 @@ impl SafeSmartAccount { /// - Will throw an error if the signature process unexpectedly fails. /// /// # Examples - /// ```rust - /// use bedrock::smart_account::{SafeSmartAccount}; + /// ```no_run + /// use std::sync::Arc; + /// use bedrock::smart_account::{ + /// SafeSmartAccount, SafeSmartAccountError, SmartAccountKeyManager, + /// }; /// use bedrock::transactions::foreign::UnparsedUserOperation; /// use bedrock::primitives::Network; /// - /// let safe = SafeSmartAccount::new( - /// // this is Anvil's default private key, it is a test secret - /// "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string(), - /// "0x4564420674EA68fcc61b463C0494807C759d47e6", - /// ) - /// .unwrap(); - /// - /// // This would normally be crafted by the user, or requested by Mini Apps. - /// let user_op = UnparsedUserOperation { - /// sender:"0xf1390a26bd60d83a4e38c7be7be1003c616296ad".to_string(), - /// nonce: "0xb14292cd79fae7d79284d4e6304fb58e21d579c13a75eed80000000000000000".to_string(), - /// call_data: "0x7bb3742800000000000000000000000079a02482a880bce3f13e09da970dc34db4cd24d10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ce2111f9ab8909b71ebadc9b6458daefe069eda4000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000".to_string(), - /// signature: "0x000012cea6000000967a7600ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string(), - /// call_gas_limit: "0xabb8".to_string(), - /// verification_gas_limit: "0xfa07".to_string(), - /// pre_verification_gas: "0x8e4d78".to_string(), - /// max_fee_per_gas: "0x1af6f".to_string(), - /// max_priority_fee_per_gas: "0x1adb0".to_string(), - /// paymaster: Some("0xEF725Aa22d43Ea69FB22bE2EBe6ECa205a6BCf5B".to_string()), - /// paymaster_verification_gas_limit: Some("0x7415".to_string()), - /// paymaster_post_op_gas_limit: Some("0x".to_string()), - /// paymaster_data: Some("000000000000000067789a97c4af0f8ae7acc9237c8f9611a0eb4662009d366b8defdf5f68fed25d22ca77be64b8eef49d917c3f8642ca539571594a84be9d0ee717c099160b79a845bea2111b".to_string()), - /// factory: None, - /// factory_data: None, - /// }; + /// fn sign( + /// key_manager: Arc, + /// ) -> Result<(), SafeSmartAccountError> { + /// let safe = SafeSmartAccount::new( + /// key_manager, + /// "0x4564420674EA68fcc61b463C0494807C759d47e6", + /// )?; /// - /// let signature = safe.sign_4337_op(Network::WorldChain as u32, user_op).unwrap(); + /// // Crafted by the user, or requested by Mini Apps. + /// let user_op = UnparsedUserOperation { + /// sender: "0xf1390a26bd60d83a4e38c7be7be1003c616296ad".to_string(), + /// nonce: "0x0".to_string(), + /// call_data: "0x".to_string(), + /// signature: "0x".to_string(), + /// call_gas_limit: "0x0".to_string(), + /// verification_gas_limit: "0x0".to_string(), + /// pre_verification_gas: "0x0".to_string(), + /// max_fee_per_gas: "0x0".to_string(), + /// max_priority_fee_per_gas: "0x0".to_string(), + /// paymaster: None, + /// paymaster_verification_gas_limit: None, + /// paymaster_post_op_gas_limit: None, + /// paymaster_data: None, + /// factory: None, + /// factory_data: None, + /// }; /// - /// println!("Signature: {}", signature.to_hex_string()); + /// let signature = safe.sign_4337_op(Network::WorldChain as u32, user_op)?; + /// println!("Signature: {}", signature.to_hex_string()); + /// Ok(()) + /// } /// ``` pub fn sign_4337_op( &self, @@ -343,9 +405,8 @@ impl SafeSmartAccount { impl SafeSmartAccount { /// Returns the underlying externally owned address (EOA) for the Safe. #[must_use] - #[expect(clippy::missing_const_for_fn, reason = "cannot be constructed const")] - pub fn eoa_address(&self) -> Address { - self.signer.address() + pub const fn eoa_address(&self) -> Address { + self.eoa_address } } @@ -400,6 +461,50 @@ pub struct SafeTransaction { pub nonce: String, } +/// Foreign-side key store that delivers the EOA private key wrapped in a +/// fresh [`SiegelSession`] each time it is needed. +/// +/// Implementations live on the Swift/Kotlin side: they fetch the secret from +/// the platform's secure storage (e.g. iOS Keychain), allocate a fresh +/// `SiegelSession` and fill it via `siegel_fill`. Rust consumes the session +/// with a single `read_once` call which zeroizes the protected buffer. +/// +/// # Foreign contract +/// +/// - **Session length**: 64 bytes — the EOA private key as ASCII hex (the +/// secp256k1 scalar is 32 raw bytes, encoded as 64 hex characters). +/// - **Byte format**: ASCII hex (lowercase or uppercase, no `0x` prefix). +/// - **Lifetime**: each call returns a brand-new session. The previous one is +/// consumed and zeroized as soon as Rust finishes signing. +/// - **Call frequency**: invoked once per signing operation (and once at +/// construction to validate the key and cache the EOA address). A typical +/// user flow may trigger several invocations. +#[uniffi::export(with_foreign)] +pub trait SmartAccountKeyManager: Send + Sync { + /// Returns a freshly allocated and filled [`SiegelSession`] containing + /// the hex-encoded EOA private key. See the [trait docs](Self) for the + /// expected length, encoding and call semantics. + fn get_eoa_private_key(&self) -> Arc; +} + +#[cfg(any(test, feature = "test_utils"))] +impl SafeSmartAccount { + /// Test-only constructor that wraps a hex-encoded private key into an + /// [`InMemoryKeyManager`](crate::test_utils::InMemoryKeyManager) before + /// delegating to [`SafeSmartAccount::new`]. + /// + /// # Errors + /// - Same conditions as [`SafeSmartAccount::new`]. + pub fn from_private_key_hex( + private_key_hex: String, + wallet_address: &str, + ) -> Result { + let manager: Arc = + Arc::new(crate::test_utils::InMemoryKeyManager::new(private_key_hex)); + Self::new(manager, wallet_address) + } +} + #[cfg(test)] impl SafeSmartAccount { /// Creates a new `SafeSmartAccount` instance with a random EOA signing key. @@ -411,12 +516,13 @@ impl SafeSmartAccount { #[must_use] pub fn random() -> Self { let signer = LocalSigner::random(); - let wallet_address = - Address::from_str("0x0000000000000000000000000000000000000000").unwrap(); // TODO: compute address correctly - Self { - signer, - wallet_address, - } + let private_key_hex = hex::encode(signer.to_bytes()); + // TODO: compute address correctly + Self::from_private_key_hex( + private_key_hex, + "0x0000000000000000000000000000000000000000", + ) + .expect("random SafeSmartAccount construction must succeed") } } @@ -441,7 +547,7 @@ mod tests { #[test] fn test_cannot_initialize_with_invalid_hex_secret() { let invalid_hex = "invalid_hex"; - let result = SafeSmartAccount::new( + let result = SafeSmartAccount::from_private_key_hex( invalid_hex.to_string(), "0x0000000000000000000000000000000000000042", ); @@ -455,7 +561,7 @@ mod tests { #[test] fn test_cannot_initialize_with_invalid_curve_point() { let invalid_hex = "2a"; // `42` is not a valid point on the curve - let result = SafeSmartAccount::new( + let result = SafeSmartAccount::from_private_key_hex( invalid_hex.to_string(), "0x0000000000000000000000000000000000000042", ); @@ -477,7 +583,7 @@ mod tests { ]; for invalid_address in invalid_addresses { - let result = SafeSmartAccount::new( + let result = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), invalid_address, ); @@ -491,7 +597,7 @@ mod tests { #[test] fn test_sign_transaction() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", @@ -515,7 +621,7 @@ mod tests { #[test] fn test_sign_4337_user_op() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", @@ -546,7 +652,7 @@ mod tests { #[test] fn test_sign_typed_data() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", diff --git a/bedrock/src/smart_account/signer.rs b/bedrock/src/smart_account/signer.rs index d4677e9e..35786412 100644 --- a/bedrock/src/smart_account/signer.rs +++ b/bedrock/src/smart_account/signer.rs @@ -7,6 +7,7 @@ use alloy::{ }; use k256::ecdsa::SigningKey; use ruint::aliases::U256; +use zeroize::Zeroizing; use crate::primitives::{address::BedrockAddress, HexEncodedData}; @@ -101,9 +102,7 @@ impl SafeSmartAccountSigner for SafeSmartAccount { chain_id: u32, ) -> Result { let message_hash = self.get_message_hash_for_safe(message, chain_id, None); - self.signer - .sign_hash_sync(&message_hash) - .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) + self.sign_hash_sync(&message_hash) } fn sign_digest( @@ -114,13 +113,44 @@ impl SafeSmartAccountSigner for SafeSmartAccount { ) -> Result { let message_hash = self.eip_712_hash(digest, chain_id, domain_separator_address); - self.signer - .sign_hash_sync(&message_hash) - .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) + self.sign_hash_sync(&message_hash) } } impl SafeSmartAccount { + /// Signs a fully encoded digest using the wallet's private key. + /// + /// The key is pulled from the [`SmartAccountKeyManager`] via a one-shot + /// siegel session that is zeroized as soon as the closure returns, so the + /// secret only lives on the Rust heap for the duration of the signature. + /// + /// # Arguments + /// - `final_digest`: the digest to sign. the output must come from a collision-resistant hash function. + fn sign_hash_sync( + &self, + final_digest: &FixedBytes<32>, + ) -> Result { + let siegel = self.key_manager.get_eoa_private_key(); + siegel.read_once(|private_key| -> Result { + // The key is passed by foreign code as hex bytes. These hex bytes need to be + // parsed. We do this in a Zeroizing closure to ensure the result gets zeroized. + let raw_key: Zeroizing> = Zeroizing::new( + hex::decode(private_key) + .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?, + ); + let signer = LocalSigner::from_slice(&raw_key) + .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?; + let signature = signer + .sign_hash_sync(final_digest) + .map_err(|e| SafeSmartAccountError::Signing(e.to_string())); + // Redundant, but added for an abundance of clarity. All copies are zeroized, only the signature + // escapes the closure. + drop(signer); + drop(raw_key); + signature + })? + } + /// Computes the digest for a specific message to be signed by the Safe Smart Account. /// /// This is equivalent to the contract's `getMessageHashForSafe` method (including also the `encodeMessageDataForSafe` logic). @@ -275,7 +305,7 @@ mod tests { fn test_get_domain_separator() { // https://optimistic.etherscan.io/address/0x4564420674EA68fcc61b463C0494807C759d47e6 - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x4564420674EA68fcc61b463C0494807C759d47e6", ) @@ -290,7 +320,7 @@ mod tests { ); // https://etherscan.io/address/0xdab5dc22350f9a6aff03cf3d9341aad0ba42d2a6 - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0xdab5dc22350f9a6aff03cf3d9341aad0ba42d2a6", ) @@ -305,7 +335,7 @@ mod tests { ); // 1.4.1 Safe - https://optimistic.etherscan.io/address/0x75c9553956dfe249c815700b1e7076a5738f3d6d#readProxyContract - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x75c9553956dfe249C815700b1E7076A5738F3d6d", ) @@ -322,7 +352,7 @@ mod tests { #[test] fn test_compute_domain_separator_world_chain() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", ) @@ -338,7 +368,7 @@ mod tests { #[test] fn test_compute_domain_separator_world_chain_alt() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x619525ED4E862B62cFEDACCc4dA5a9864D6f4A97", ) @@ -356,7 +386,7 @@ mod tests { /// Reference: #[test] fn test_get_message_hash_for_safe() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x4564420674EA68fcc61b463C0494807C759d47e6", ) @@ -382,7 +412,7 @@ mod tests { /// Reference: #[test] fn test_get_message_hash_for_safe_alt() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x4564420674EA68fcc61b463C0494807C759d47e6", ) @@ -410,7 +440,7 @@ mod tests { /// Reference: #[test] fn test_get_message_hash_for_safe_ethereum_chain() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0xdab5dc22350f9a6aff03cf3d9341aad0ba42d2a6", ) diff --git a/bedrock/src/smart_account/transaction_4337.rs b/bedrock/src/smart_account/transaction_4337.rs index 33a42416..c5ba2c03 100644 --- a/bedrock/src/smart_account/transaction_4337.rs +++ b/bedrock/src/smart_account/transaction_4337.rs @@ -481,7 +481,7 @@ mod tests { #[test] fn test_sign_user_operation_produces_valid_77_byte_signature() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", diff --git a/bedrock/src/test_utils.rs b/bedrock/src/test_utils.rs index 92c775ba..af5ce30b 100644 --- a/bedrock/src/test_utils.rs +++ b/bedrock/src/test_utils.rs @@ -1,6 +1,7 @@ //! Test utilities for unit tests and E2E tests for mocking RPC responses either from Anvil or hard-coded for unit tests. #![allow(clippy::all)] use std::str::FromStr; +use std::sync::Arc; use alloy::{ network::Ethereum, @@ -9,16 +10,66 @@ use alloy::{ sol, sol_types::{SolEvent, SolValue}, }; +use siegel_uniffi::{siegel_fill, SiegelSession, FILL_OK}; +use zeroize::{Zeroize, ZeroizeOnDrop}; use crate::{ primitives::{ http_client::{AuthenticatedHttpClient, HttpError, HttpHeader, HttpMethod}, PrimitiveError, }, - smart_account::UserOperation, + smart_account::{SmartAccountKeyManager, UserOperation}, transactions::foreign::UnparsedUserOperation, }; +/// In-memory [`SmartAccountKeyManager`] for unit and integration tests. +/// +/// Wraps a hex-encoded private key as bytes and seeds a fresh +/// [`SiegelSession`] on every access mirroring how a real key store +/// (e.g. iOS Keychain) would deliver the secret one use at a time. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct InMemoryKeyManager { + hex_bytes: Vec, +} + +impl InMemoryKeyManager { + /// Wraps a hex-encoded private key. + #[must_use] + pub const fn new(private_key_hex: String) -> Self { + // Normally we'd verify the sk length here, but because we have tests that require + // passing invalid secrets, we don't enforce it. + Self { + hex_bytes: private_key_hex.into_bytes(), + } + } +} + +impl SmartAccountKeyManager for InMemoryKeyManager { + fn get_eoa_private_key(&self) -> Arc { + let len = + u32::try_from(self.hex_bytes.len()).expect("secret len must fit in u32"); + let session = SiegelSession::new(len).expect("failed to create siegel session"); + // SAFETY: + // - `session.handle()` was just returned by `SiegelSession::new` and the + // session is still alive (held by `session` until the end of this fn). + // - `self.hex_bytes` is owned by `&self` and lives until the function + // returns, so the pointer is valid for `self.hex_bytes.len()` reads. + // - the session was allocated with capacity `len == self.hex_bytes.len()`, + // matching what `siegel_fill` expects. + let rc = unsafe { + siegel_fill( + session.handle(), + self.hex_bytes.as_ptr(), + self.hex_bytes.len(), + ) + }; + // Test-only: a non-OK return code indicates broken test setup, not a + // user-actionable error, so we panic with the raw status. + assert!(rc == FILL_OK, "siegel_fill failed with code {rc}"); + session + } +} + /// Represents a response from '`wa_sponsorUserOperation`' rpc method #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] diff --git a/bedrock/src/transactions/mod.rs b/bedrock/src/transactions/mod.rs index 61592f75..f2f76ac0 100644 --- a/bedrock/src/transactions/mod.rs +++ b/bedrock/src/transactions/mod.rs @@ -64,14 +64,17 @@ impl SafeSmartAccount { /// /// # Example /// - /// ```rust,no_run - /// use bedrock::smart_account::SafeSmartAccount; + /// ```no_run + /// use std::sync::Arc; + /// use bedrock::smart_account::{SafeSmartAccount, SmartAccountKeyManager}; /// use bedrock::transactions::TransactionError; /// use bedrock::primitives::Network; /// - /// # async fn example() -> Result<(), TransactionError> { + /// # async fn example( + /// # key_manager: Arc, + /// # ) -> Result<(), TransactionError> { /// // Assume we have a configured SafeSmartAccount - /// # let safe_account = SafeSmartAccount::new("test_key".to_string(), "0x1234567890123456789012345678901234567890").unwrap(); + /// # let safe_account = SafeSmartAccount::new(key_manager, "0x1234567890123456789012345678901234567890").unwrap(); /// /// // Transfer USDC on World Chain /// let tx_hash = safe_account.transaction_transfer( diff --git a/bedrock/tests/test_permit2_approval_processor.rs b/bedrock/tests/test_permit2_approval_processor.rs index a27c8fba..fd5ed27a 100644 --- a/bedrock/tests/test_permit2_approval_processor.rs +++ b/bedrock/tests/test_permit2_approval_processor.rs @@ -56,7 +56,7 @@ async fn test_permit2_approval_processor_full_flow() -> anyhow::Result<()> { set_http_client(Arc::new(client)); // 6) Create the processor - let safe_account = Arc::new(SafeSmartAccount::new( + let safe_account = Arc::new(SafeSmartAccount::from_private_key_hex( owner_key_hex, &safe_address.to_string(), )?); diff --git a/bedrock/tests/test_smart_account_bundler_sponsored.rs b/bedrock/tests/test_smart_account_bundler_sponsored.rs index 164cc0ff..372dd8c9 100644 --- a/bedrock/tests/test_smart_account_bundler_sponsored.rs +++ b/bedrock/tests/test_smart_account_bundler_sponsored.rs @@ -182,7 +182,10 @@ async fn test_send_bundler_sponsored_user_operation() -> anyhow::Result<()> { }; // 9) Execute via send_bundler_sponsored_user_operation - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; let _user_op_hash = safe_account .send_bundler_sponsored_user_operation(unparsed_user_op, bundler_url.clone()) .await @@ -268,7 +271,8 @@ async fn test_send_bundler_sponsored_user_operation_live() -> anyhow::Result<()> factory_data: None, }; - let safe_account = SafeSmartAccount::new(owner_key, &safe_address.to_string())?; + let safe_account = + SafeSmartAccount::from_private_key_hex(owner_key, &safe_address.to_string())?; let user_op_hash = safe_account .send_bundler_sponsored_user_operation(unparsed_user_op, rpc_url) .await @@ -301,8 +305,9 @@ async fn test_send_bundler_sponsored_user_operation_bundler_rejected() { ); let owner_key_hex = hex::encode(alloy::signers::local::PrivateKeySigner::random().to_bytes()); - let safe_account = SafeSmartAccount::new(owner_key_hex, safe_address) - .expect("failed to create SafeSmartAccount"); + let safe_account = + SafeSmartAccount::from_private_key_hex(owner_key_hex, safe_address) + .expect("failed to create SafeSmartAccount"); let err = safe_account .send_bundler_sponsored_user_operation( @@ -337,8 +342,9 @@ async fn test_send_bundler_sponsored_user_operation_http_error_is_generic() { let owner_key_hex = hex::encode(alloy::signers::local::PrivateKeySigner::random().to_bytes()); let safe_address = "0x1234567890123456789012345678901234567890"; - let safe_account = SafeSmartAccount::new(owner_key_hex, safe_address) - .expect("failed to create SafeSmartAccount"); + let safe_account = + SafeSmartAccount::from_private_key_hex(owner_key_hex, safe_address) + .expect("failed to create SafeSmartAccount"); let err = safe_account .send_bundler_sponsored_user_operation( diff --git a/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs b/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs index e1c9cd14..6c70cc04 100644 --- a/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs +++ b/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs @@ -94,8 +94,11 @@ async fn test_integration_erc4337_transaction_execution() -> anyhow::Result<()> let mut user_op: UserOperation = user_op.try_into().unwrap(); // Sign the userOp and prepend validity timestamps - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let (va, vu) = user_op.extract_validity_timestamps()?; let op_hash = EncodedSafeOpStruct::from_user_op_with_validity(&user_op, va, vu) .unwrap() diff --git a/bedrock/tests/test_smart_account_morpho.rs b/bedrock/tests/test_smart_account_morpho.rs index 8f0f29e8..a071bd44 100644 --- a/bedrock/tests/test_smart_account_morpho.rs +++ b/bedrock/tests/test_smart_account_morpho.rs @@ -91,7 +91,10 @@ async fn test_erc4626_deposit_wld() -> anyhow::Result<()> { set_http_client(Arc::new(client)); // 8) Create and execute ERC4626 deposit using the generic implementation - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; // First create the transaction to log call data for unit tests let rpc_client = bedrock::transactions::rpc::get_rpc_client().unwrap(); diff --git a/bedrock/tests/test_smart_account_permit2_transfer.rs b/bedrock/tests/test_smart_account_permit2_transfer.rs index 21b20001..66927e70 100644 --- a/bedrock/tests/test_smart_account_permit2_transfer.rs +++ b/bedrock/tests/test_smart_account_permit2_transfer.rs @@ -87,7 +87,10 @@ async fn test_integration_permit2_transfer() -> anyhow::Result<()> { // Step 2: Deploy a Safe (World App User) let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; // Step 3: Give the Safe some simulated WLD balance let wld_token_address = address!("0x2cFc85d8E48F8EAB294be644d9E25C3030863003"); @@ -243,7 +246,10 @@ async fn test_integration_permit2_approve_and_allowance_transfer() -> anyhow::Re // Step 2: Deploy a Safe (World App User) let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; // Step 3: Give the Safe some simulated WLD balance let wld_token_address = address!("0x2cFc85d8E48F8EAB294be644d9E25C3030863003"); diff --git a/bedrock/tests/test_smart_account_personal_sign.rs b/bedrock/tests/test_smart_account_personal_sign.rs index 9bb62965..2c6692c9 100644 --- a/bedrock/tests/test_smart_account_personal_sign.rs +++ b/bedrock/tests/test_smart_account_personal_sign.rs @@ -38,8 +38,11 @@ async fn test_integration_personal_sign() { let message = "Hello from Safe integration test!"; let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let signature = safe_account .personal_sign(chain_id, message.to_string()) @@ -119,8 +122,11 @@ async fn test_integration_personal_sign_failure_on_incorrect_chain_id() { let message = "Hello from Safe integration test!"; let chain_id = 10; // Note: This is not World Chain, verification will fail - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let signature = safe_account .personal_sign(chain_id, message.to_string()) @@ -180,8 +186,11 @@ async fn test_integration_personal_sign_failure_on_incorrect_eip_191_prefix() { let message = "Hello from Safe integration test!"; let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let signature = safe_account .personal_sign(chain_id, message.to_string()) diff --git a/bedrock/tests/test_smart_account_sign_typed_data.rs b/bedrock/tests/test_smart_account_sign_typed_data.rs index 284929e6..be5e8738 100644 --- a/bedrock/tests/test_smart_account_sign_typed_data.rs +++ b/bedrock/tests/test_smart_account_sign_typed_data.rs @@ -105,8 +105,11 @@ async fn test_integration_sign_typed_data() { let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let signature = safe_account .sign_typed_data(chain_id, &typed_data.to_string()) diff --git a/bedrock/tests/test_smart_account_transfer.rs b/bedrock/tests/test_smart_account_transfer.rs index e62c5bb5..45ff6f3b 100644 --- a/bedrock/tests/test_smart_account_transfer.rs +++ b/bedrock/tests/test_smart_account_transfer.rs @@ -71,7 +71,10 @@ async fn test_transaction_transfer_full_flow_executes_user_operation( set_http_client(Arc::new(client)); // 8) Execute high-level transfer via transaction_transfer - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; let amount = "1000000000000000000"; // 1 WLD let _user_op_hash = safe_account .transaction_transfer( diff --git a/bedrock/tests/test_smart_account_usd_vault.rs b/bedrock/tests/test_smart_account_usd_vault.rs index 850bb2c7..825451ec 100644 --- a/bedrock/tests/test_smart_account_usd_vault.rs +++ b/bedrock/tests/test_smart_account_usd_vault.rs @@ -54,7 +54,10 @@ async fn test_usd_vault_migration() -> anyhow::Result<()> { let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; println!("✓ Deployed Safe at: {safe_address}"); - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; provider .anvil_set_balance(safe_address, parse_ether("1").unwrap()) diff --git a/bedrock/tests/test_smart_account_wa_get_user_operation_receipt.rs b/bedrock/tests/test_smart_account_wa_get_user_operation_receipt.rs index eeab7e26..ed767765 100644 --- a/bedrock/tests/test_smart_account_wa_get_user_operation_receipt.rs +++ b/bedrock/tests/test_smart_account_wa_get_user_operation_receipt.rs @@ -28,8 +28,10 @@ async fn test_wa_get_user_operation_receipt_uses_mocked_response() -> anyhow::Re set_http_client(Arc::new(client)); // Construct a SafeSmartAccount; the on-chain state is irrelevant for this test - let safe_account = - SafeSmartAccount::new(owner_key_hex, &owner_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &owner_address.to_string(), + )?; let user_op_hash = "0x3a9b7d5e1f0a4c2e6b8d7f9a1c3e5f0b2d4a6c8e9f1b3d5c7a9e0f2c4b6d8a0"; diff --git a/bedrock/tests/test_smart_account_wld_vault.rs b/bedrock/tests/test_smart_account_wld_vault.rs index 1a7496a0..d112c1e8 100644 --- a/bedrock/tests/test_smart_account_wld_vault.rs +++ b/bedrock/tests/test_smart_account_wld_vault.rs @@ -60,7 +60,10 @@ async fn test_wld_vault_migration() -> anyhow::Result<()> { let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; println!("✓ Deployed Safe at: {safe_address}"); - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; provider .anvil_set_balance(safe_address, parse_ether("1").unwrap()) diff --git a/bedrock/tests/test_smart_account_world_gift_manager_gift_cancel.rs b/bedrock/tests/test_smart_account_world_gift_manager_gift_cancel.rs index 144a0b8c..397990f3 100644 --- a/bedrock/tests/test_smart_account_world_gift_manager_gift_cancel.rs +++ b/bedrock/tests/test_smart_account_world_gift_manager_gift_cancel.rs @@ -64,8 +64,10 @@ async fn test_transaction_world_gift_manager_gift_cancel_user_operations( set_http_client(Arc::new(client)); - let safe_account_giftor = - SafeSmartAccount::new(owner_key_hex.clone(), &safe_address_giftor.to_string())?; + let safe_account_giftor = SafeSmartAccount::from_private_key_hex( + owner_key_hex.clone(), + &safe_address_giftor.to_string(), + )?; let amount = U256::from(1e18); let gift_result = safe_account_giftor diff --git a/bedrock/tests/test_smart_account_world_gift_manager_gift_redeem.rs b/bedrock/tests/test_smart_account_world_gift_manager_gift_redeem.rs index 887644b1..a3d88e70 100644 --- a/bedrock/tests/test_smart_account_world_gift_manager_gift_redeem.rs +++ b/bedrock/tests/test_smart_account_world_gift_manager_gift_redeem.rs @@ -72,10 +72,14 @@ async fn test_transaction_world_gift_manager_gift_redeem_user_operations( set_http_client(Arc::new(client)); - let safe_account_giftor = - SafeSmartAccount::new(owner_key_hex.clone(), &safe_address_giftor.to_string())?; - let safe_account_giftee = - SafeSmartAccount::new(owner_key_hex.clone(), &safe_address_giftee.to_string())?; + let safe_account_giftor = SafeSmartAccount::from_private_key_hex( + owner_key_hex.clone(), + &safe_address_giftor.to_string(), + )?; + let safe_account_giftee = SafeSmartAccount::from_private_key_hex( + owner_key_hex.clone(), + &safe_address_giftee.to_string(), + )?; let amount = U256::from(1e18); let gift_result = safe_account_giftor