diff --git a/Cargo.lock b/Cargo.lock index 15a36b447..388b9092d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,6 +147,21 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bip32" version = "0.4.0" @@ -165,6 +180,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bitcoin_hashes" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90064b8dee6815a6470d60bad07bbbaee885c0e12d04177138fa3291a01b7bc4" + [[package]] name = "bitflags" version = "1.3.2" @@ -852,6 +873,24 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cw-verifier-middleware" +version = "0.1.0" +dependencies = [ + "bech32", + "bip32", + "cosmwasm-schema", + "cosmwasm-std", + "cw-storage-plus 1.0.1 (git+https://github.com/DA0-DA0/cw-storage-plus.git)", + "cw-utils 0.16.0", + "hex", + "ripemd", + "secp256k1", + "serde_json", + "sha2 0.10.6", + "thiserror", +] + [[package]] name = "cw-vesting" version = "2.0.3" @@ -1770,6 +1809,13 @@ dependencies = [ "syn", ] +[[package]] +name = "derive" +version = "0.1.0" +dependencies = [ + "dao-macros", +] + [[package]] name = "digest" version = "0.9.0" @@ -2989,6 +3035,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secp256k1" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4124a35fe33ae14259c490fd70fa199a32b9ce9502f2ee6bc4f81ec06fa65894" +dependencies = [ + "bitcoin_hashes", + "rand", + "secp256k1-sys", +] + +[[package]] +name = "secp256k1-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642a62736682fdd8c71da0eb273e453c8ac74e33b9fb310e22ba5b03ec7651ff" +dependencies = [ + "cc", +] + [[package]] name = "security-framework" version = "2.8.2" @@ -3781,6 +3847,22 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +[[package]] +name = "verifier-test" +version = "0.1.0" +dependencies = [ + "bincode", + "cosmwasm-schema", + "cosmwasm-std", + "cosmwasm-storage", + "cw-multi-test", + "cw-storage-plus 1.0.1 (git+https://github.com/DA0-DA0/cw-storage-plus.git)", + "cw-verifier-middleware", + "cw2 0.16.0", + "dao-testing", + "thiserror", +] + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index c0581023b..3df1d31b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,8 @@ members = [ "packages/*", "test-contracts/*", "ci/*", - "contracts/external/*" + "contracts/external/*", + "contracts/external/cw-verifier-middleware/derive" ] exclude = ["ci/configs/"] @@ -49,6 +50,7 @@ cw4 = "0.16" cw4-group = "0.16" cw721 = "0.16" cw721-base = "0.16" +serde_json = "1.0" proc-macro2 = "1.0" quote = "1.0" rand = "0.8" @@ -56,12 +58,20 @@ serde = { version = "1.0", default-features = false, features = ["derive"]} syn = { version = "1.0", features = ["derive"] } thiserror = { version = "1.0.30" } wynd-utils = "0.4.1" +secp256k1 = "0.26.0" +sha2 = "0.10.6" +bip32 = "0.4.0" +hex = "0.4.3" +ripemd = "0.1.3" +bech32 = "0.9.1" +bincode = "1.3" # One commit ahead of version 0.3.0. Allows initialization with an # optional owner. cw-ownable = { git = "https://github.com/steak-enjoyers/cw-plus-plus", rev = "50d4d9333305894457e5028072a0465f4635b15b" } cw-admin-factory = { path = "./contracts/external/cw-admin-factory" } +cw-verifier-middleware = { path = "./contracts/external/cw-verifier-middleware"} cw-denom = { path = "./packages/cw-denom", version = "*" } cw-hooks = { path = "./packages/cw-hooks", version = "*" } cw-wormhole = { path = "./packages/cw-wormhole", version = "*" } @@ -93,6 +103,7 @@ dao-voting-cw4 = { path = "./contracts/voting/dao-voting-cw4", version = "*" } dao-voting-cw721-staked = { path = "./contracts/voting/dao-voting-cw721-staked", version = "*" } dao-voting-native-staked = { path = "./contracts/voting/dao-voting-native-staked", version = "*" } + # v1 dependencies. used for state migrations. cw-core-v1 = { package = "cw-core", version = "0.1.0", git = "https://github.com/DA0-DA0/dao-contracts.git", tag = "v1.0.0" } cw-proposal-single-v1 = { package = "cw-proposal-single", version = "0.1.0", git = "https://github.com/DA0-DA0/dao-contracts.git", tag = "v1.0.0" } diff --git a/contracts/external/cw-verifier-middleware/Cargo.toml b/contracts/external/cw-verifier-middleware/Cargo.toml new file mode 100644 index 000000000..a28873063 --- /dev/null +++ b/contracts/external/cw-verifier-middleware/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "cw-verifier-middleware" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +cosmwasm-std = { workspace = true} +thiserror = { workspace = true } +sha2 = { workspace = true } +cosmwasm-schema = { workspace = true } +cw-storage-plus = { workspace = true } +cw-utils = {workspace = true} +hex = { workspace = true } +bip32 = { workspace = true } +ripemd = { workspace = true } +bech32 = { workspace = true } +serde_json = { workspace = true} +secp256k1 = { workspace = true } + +[dev-dependencies] +secp256k1 = { workspace = true, features = ["rand-std", "bitcoin-hashes-std"] } diff --git a/contracts/external/cw-verifier-middleware/README.md b/contracts/external/cw-verifier-middleware/README.md new file mode 100644 index 000000000..8bef1d4d5 --- /dev/null +++ b/contracts/external/cw-verifier-middleware/README.md @@ -0,0 +1,3 @@ +TODO + +![](https://user-images.githubusercontent.com/30676292/214428970-aabed2eb-7271-4a91-a641-23d004f04512.png) diff --git a/contracts/external/cw-verifier-middleware/derive/Cargo.toml b/contracts/external/cw-verifier-middleware/derive/Cargo.toml new file mode 100644 index 000000000..910b9b791 --- /dev/null +++ b/contracts/external/cw-verifier-middleware/derive/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "derive" +version = "0.1.0" +edition = "2021" + +[dependencies] +dao-macros = { workspace = true } +cosmwasm-std = { workspace = true} +serde = { workspace = true, features = ["derive"]} +serde_derive = { workspace = true } +serde_json = { workspace = true } +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true } + +[lib] +name = "derive" +path = "lib.rs" +proc-macro = true diff --git a/contracts/external/cw-verifier-middleware/derive/lib.rs b/contracts/external/cw-verifier-middleware/derive/lib.rs new file mode 100644 index 000000000..da4110aaa --- /dev/null +++ b/contracts/external/cw-verifier-middleware/derive/lib.rs @@ -0,0 +1,21 @@ +use cosmwasm_std::{DepsMut, Env, MessageInfo, Response}; +use proc_macro::TokenStream; +use quote::quote; +use serde::de::DeserializeOwned; +use std::error::Error; + +#[proc_macro_attribute] +pub fn cw_verifier_execute(metadata: TokenStream, input: TokenStream) -> TokenStream { + merge_variants( + metadata, + input, + quote! { + enum Right { + VerifyAndExecuteSignedMessage { + msg: ::cw_verifier_middleware::WrappedMessage + }, + } + } + .into(), + ) +} diff --git a/contracts/external/cw-verifier-middleware/src/error.rs b/contracts/external/cw-verifier-middleware/src/error.rs new file mode 100644 index 000000000..1a4d1cc1c --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/error.rs @@ -0,0 +1,44 @@ +use bech32::Error as Bech32Error; + +use cosmwasm_std::OverflowError; +use cosmwasm_std::{StdError, VerificationError}; +use hex::FromHexError; +use secp256k1::Error as Secp256k1Error; +use serde_json::Error as SerdeError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + FromHexError(#[from] FromHexError), + + #[error("{0}")] + VerificationError(#[from] VerificationError), + + #[error("{0}")] + Bech32Error(#[from] Bech32Error), + + #[error("{0}")] + Secp256k1Error(#[from] Secp256k1Error), + + #[error("{0}")] + OverflowError(#[from] OverflowError), + + #[error("{0}")] + SerdeError(#[from] SerdeError), + + #[error("Invalid nonce")] + InvalidNonce, + + #[error("Message expiration has passed")] + MessageExpired, + + #[error("Message signature is invalid")] + SignatureInvalid, + + #[error("Invalid uncompressed public key hex string length; expected 130 bytes, got {length}")] + InvalidPublicKeyLength { length: usize }, +} diff --git a/contracts/external/cw-verifier-middleware/src/lib.rs b/contracts/external/cw-verifier-middleware/src/lib.rs new file mode 100644 index 000000000..56cb0db19 --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/lib.rs @@ -0,0 +1,10 @@ +#![doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))] + +pub mod error; +pub mod msg; +pub mod state; +pub mod utils; +pub mod verify; + +#[cfg(test)] +mod testing; diff --git a/contracts/external/cw-verifier-middleware/src/msg.rs b/contracts/external/cw-verifier-middleware/src/msg.rs new file mode 100644 index 000000000..4fde0ce4b --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/msg.rs @@ -0,0 +1,22 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{Binary, HexBinary, Uint128}; +use cw_utils::Expiration; + +#[cw_serde] +pub struct WrappedMessage { + pub payload: Payload, + // Assumes signature is 'payload' hashed, signed, and base64 encoded + pub signature: Binary, + pub public_key: HexBinary, // hex encoded +} + +#[cw_serde] +pub struct Payload { + pub nonce: Uint128, + pub contract_address: String, + pub chain_id: String, + pub msg: Binary, + pub expiration: Option, + pub bech32_prefix: String, + pub contract_version: String, +} diff --git a/contracts/external/cw-verifier-middleware/src/state.rs b/contracts/external/cw-verifier-middleware/src/state.rs new file mode 100644 index 000000000..b2db0705b --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/state.rs @@ -0,0 +1,11 @@ +use cosmwasm_std::{Addr, Uint128}; +use cw_storage_plus::{Item, Map}; + +/// Nonce for each public key, contract addr, contract version thruple +pub const NONCES: Map<(&str, &Addr, &str), Uint128> = Map::new("pk_to_nonce"); + +/// Contract address for which this middleware is used. +/// We require the contract address as part of the +/// payload to prevent replay attacks across contracts (a nonce may be used multiple times if there is no other +/// way to determine that it has already be used). +pub const CONTRACT_ADDRESS: Item = Item::new("contract_address"); diff --git a/contracts/external/cw-verifier-middleware/src/testing/adversarial_tests.rs b/contracts/external/cw-verifier-middleware/src/testing/adversarial_tests.rs new file mode 100644 index 000000000..f4c513987 --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/testing/adversarial_tests.rs @@ -0,0 +1,6 @@ +// TODO: test where a wrapped message passed to the verifier contains +// a payload with a large (in size) msg that was pre-signed +#[test] +fn test_verify_big_payload() { +} + diff --git a/contracts/external/cw-verifier-middleware/src/testing/mod.rs b/contracts/external/cw-verifier-middleware/src/testing/mod.rs new file mode 100644 index 000000000..9f1e9f269 --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/testing/mod.rs @@ -0,0 +1,2 @@ +mod adversarial_tests; +mod tests; diff --git a/contracts/external/cw-verifier-middleware/src/testing/tests.rs b/contracts/external/cw-verifier-middleware/src/testing/tests.rs new file mode 100644 index 000000000..77e1866a9 --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/testing/tests.rs @@ -0,0 +1,487 @@ +use cosmwasm_schema::cw_serde; +use cosmwasm_std::{ + testing::{mock_dependencies, mock_env, mock_info}, + to_binary, Addr, BlockInfo, DepsMut, HexBinary, Uint128, VerificationError, +}; +use cw_utils::Expiration; +use secp256k1::{hashes::hex::ToHex, rand::rngs::OsRng, Message, PublicKey, Secp256k1, SecretKey}; +use sha2::{Digest, Sha256}; + +use crate::{ + error::ContractError, + msg::{Payload, WrappedMessage}, + state::NONCES, + utils::get_wrapped_msg, + verify::{get_sign_doc, pk_to_addr, verify}, +}; + +pub const JUNO_ADDRESS: &str = "juno1muw4rz9ml44wc6vssqrzkys4nuc3gylrxj4flw"; +pub const COMPRESSED_PK: &str = + "03f620cd2e33d3f6af5a43d5b3ca3b9b7f653aa980ae56714cc5eb7637fd1eeb28"; +pub const UNCOMPRESSED_PK: &str = "04f620cd2e33d3f6af5a43d5b3ca3b9b7f653aa980ae56714cc5eb7637fd1eeb28fb722c0dacb5f005f583630dae8bbe7f5eaba70f129fc279d7ff421ae8c9eb79"; +pub const JUNO_PREFIX: &str = "juno"; + +#[cw_serde] +pub enum TestExecuteMsg { + Test, +} + +#[test] +fn test_pk_to_addr_uncompressed() { + let deps = mock_dependencies(); + let generated_address = + pk_to_addr(&deps.api, UNCOMPRESSED_PK.to_string(), JUNO_PREFIX).unwrap(); + + assert_eq!(generated_address, Addr::unchecked(JUNO_ADDRESS)); +} + +#[test] +fn test_pk_to_addr_compressed() { + let deps = mock_dependencies(); + let generated_address = pk_to_addr(&deps.api, COMPRESSED_PK.to_string(), JUNO_PREFIX).unwrap(); + assert_eq!(generated_address, Addr::unchecked(JUNO_ADDRESS)); +} + +#[test] +fn test_pk_to_addr_invalid_hex_length() { + let invalid_length_pk = "".to_string(); + let deps = mock_dependencies(); + let err: ContractError = pk_to_addr(&deps.api, invalid_length_pk, JUNO_PREFIX).unwrap_err(); + + assert!(matches!(err, ContractError::InvalidPublicKeyLength { .. })); +} + +#[test] +fn test_pk_to_addr_not_hex_pk() { + let non_hex_pk = + "03zzzzcd2e33d3f6af5a43d5b3ca3b9b7f653aa980ae56714cc5eb7637fd1eeb28".to_string(); + let deps = mock_dependencies(); + let err: ContractError = pk_to_addr(&deps.api, non_hex_pk, JUNO_PREFIX).unwrap_err(); + + assert!(matches!(err, ContractError::FromHexError { .. })); +} + +#[test] +fn test_pk_to_addr_bech32_invalid_human_readable_part() { + let deps = mock_dependencies(); + let err: ContractError = + pk_to_addr(&deps.api, UNCOMPRESSED_PK.to_string(), "jUnO").unwrap_err(); + + assert!(matches!(err, ContractError::Bech32Error { .. })); +} + +#[test] +fn test_verify_success() { + // This test generates a payload in which the signature is base64 encoded, and the public key is hex encoded. + // The test then calls verify to validate that the signature is correctly verified. + + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary(&TestExecuteMsg::Test {}).unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: "juno".to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + let mut deps = mock_dependencies(); + let wrapped_msg = get_wrapped_msg(&deps.api, payload.clone()); + + // Verify + let env = mock_env(); + let info = mock_info("creator", &[]); + verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ) + .unwrap(); + + // Verify nonce was incremented correctly + let nonce = NONCES + .load( + &deps.storage, + ( + &wrapped_msg.public_key.to_hex(), + &Addr::unchecked(payload.contract_address), + &payload.contract_version, + ), + ) + .unwrap(); + assert_eq!(nonce, Uint128::from(1u128)) +} + +// The type that verify deserializes to does not match the serialized message type. +#[test] +fn test_verify_wrong_message_type() { + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary("eyJpbnN0YW50aWF0ZV9jb250cmFjdF93aXRoX3NlbGZfYWRtaW4iOnsiY29kZV9pZCI6MTY4OCwiaW5zdGFudGlhdGVfbXNnIjp7fX19ICA=").unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: "juno".to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + let mut deps = mock_dependencies(); + let wrapped_msg = get_wrapped_msg(&deps.api, payload.clone()); + + // Verify + let env = mock_env(); + let info = mock_info("creator", &[]); + let res = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ); + + assert!(matches!(res, Err(ContractError::Std(_)))); +} + +#[test] +fn test_verify_invalid_pk() { + // This test generates a payload in which the signature is of base64 format, and the public key is of hex format. + // The test then calls verify with an incorrectly formatted public key to validate that there is an error in parsing the public key. + + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary("test").unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: "juno".to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + // Generate wrapped message + let mut deps = mock_dependencies(); + let mut wrapped_msg = get_wrapped_msg(&deps.api, payload); + + // Set public key to invalid + wrapped_msg.public_key = Vec::from("incorrect_public_key").into(); + + // Verify with incorrect public key + let env = mock_env(); + let info = mock_info("creator", &[]); + let result = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ); + + // Ensure that there was a pub key parsing error + assert!(matches!( + result, + Err(ContractError::InvalidPublicKeyLength { .. }) + )); +} + +#[test] +fn test_verify_wrong_pk() { + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary(&TestExecuteMsg::Test {}).unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: "juno".to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + // Generate a keypair + let secp = Secp256k1::new(); + let (secret_key, _) = secp.generate_keypair(&mut OsRng); + + // Hash and sign the payload + let msg_hash = Sha256::digest(&to_binary(&payload).unwrap()); + let msg = Message::from_slice(&msg_hash).unwrap(); + let sig = secp.sign_ecdsa(&msg, &secret_key); + + // Generate another keypair + let secp = Secp256k1::new(); + let (_, public_key) = secp.generate_keypair(&mut OsRng); + + // Wrap the message but with incorrect public key + let wrapped_msg = WrappedMessage { + payload, + signature: sig.serialize_compact().into(), + public_key: HexBinary::from(public_key.serialize_uncompressed()), + }; + + // Verify with incorrect public key + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("creator", &[]); + let result = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ); + + // Ensure that there was a signature verification error + assert!(matches!(result, Err(ContractError::SignatureInvalid))); +} + +#[test] +fn test_verify_incorrect_nonce() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("creator", &[]); + + // get a default wrapped message and verify it + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary(&TestExecuteMsg::Test {}).unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: JUNO_PREFIX.to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + let wrapped_msg = get_wrapped_msg(&deps.api, payload); + verify::( + &deps.api, + &mut deps.storage, + &env, + info.clone(), + wrapped_msg.clone(), + ) + .unwrap(); + + // skip a nonce iteration + let invalid_nonce_payload = Payload { + nonce: Uint128::from(3u128), + msg: to_binary("eyJpbnN0YW50aWF0ZV9jb250cmFjdF93aXRoX3NlbGZfYWRtaW4iOnsiY29kZV9pZCI6MTY4OCwiaW5zdGFudGlhdGVfbXNnIjp7fX19ICA=").unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: JUNO_PREFIX.to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + let wrapped_msg = get_wrapped_msg(&deps.api, invalid_nonce_payload); + let err = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ) + .unwrap_err(); + + // verify the invalid nonce error + assert!(matches!(err, ContractError::InvalidNonce)); +} + +#[test] +fn test_verify_expired_message() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("creator", &[]); + + // get an expired message + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary("eyJpbnN0YW50aWF0ZV9jb250cmFjdF93aXRoX3NlbGZfYWRtaW4iOnsiY29kZV9pZCI6MTY4OCwiaW5zdGFudGlhdGVfbXNnIjp7fX19ICA=").unwrap(), + expiration: Some(cw_utils::Expiration::AtHeight(0)), + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: JUNO_PREFIX.to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + let wrapped_msg = get_wrapped_msg(&deps.api, payload); + + let err: ContractError = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ) + .unwrap_err(); + + assert!(matches!(err, ContractError::MessageExpired)); +} + +#[test] +fn test_verify_wrong_payload() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("creator", &[]); + + // Generate a keypair + let secp = Secp256k1::new(); + let (secret_key, public_key) = secp.generate_keypair(&mut OsRng); + + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary("eyJpbnN0YW50aWF0ZV9jb250cmFjdF93aXRoX3NlbGZfYWRtaW4iOnsiY29kZV9pZCI6MTY4OCwiaW5zdGFudGlhdGVfbXNnIjp7fX19ICA=").unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: JUNO_PREFIX.to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + // Hash and sign the payload + let msg_hash = Sha256::digest(&to_binary(&payload).unwrap()); + let msg = Message::from_slice(&msg_hash).unwrap(); + let sig = secp.sign_ecdsa(&msg, &secret_key); + + let hex_encoded = HexBinary::from(public_key.serialize_uncompressed()); + + // Wrap a different message with the existing signature + let different_payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary("eyJpbnN0YW50aWF0ZV9jb250cmFjdF93aXRoX3NlbGZfYWRtaW4iOnsiY29kZV9pZCI6MTY4OCwiaW5zdGFudGlhdGVfbXNnIjp7fX19ICA=").unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: "cosmos".to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + let wrapped_msg = WrappedMessage { + payload: different_payload, + signature: sig.serialize_compact().into(), + public_key: hex_encoded.clone(), + }; + + let err: ContractError = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ) + .unwrap_err(); + + assert!(matches!(err, ContractError::SignatureInvalid { .. })); +} + +#[test] +fn test_verify_malformed_signature() { + let mut deps = mock_dependencies(); + let env = mock_env(); + let info = mock_info("creator", &[]); + + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary("eyJpbnN0YW50aWF0ZV9jb250cmFjdF93aXRoX3NlbGZfYWRtaW4iOnsiY29kZV9pZCI6MTY4OCwiaW5zdGFudGlhdGVfbXNnIjp7fX19ICA=").unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: JUNO_PREFIX.to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + let mut wrapped_msg = get_wrapped_msg(&deps.api, payload); + let malformed_sig = Vec::from("malformed signature"); + wrapped_msg.signature = malformed_sig.into(); + + let err: ContractError = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ) + .unwrap_err(); + assert!(matches!(err, ContractError::VerificationError { .. })); +} + +// Verify that sender's address is set correctly in info. +#[test] +fn test_verify_correct_address() { + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary(&TestExecuteMsg::Test {}).unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: "juno".to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + let mut deps = mock_dependencies(); + + let wrapped_msg = get_wrapped_msg(&deps.api, payload); + + let mut env = mock_env(); + env.block.height = 1; + let info = mock_info("creator", &[]); + let (_, verified_info) = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ) + .unwrap(); + + let addr = pk_to_addr(&deps.api, wrapped_msg.public_key.to_hex(), JUNO_PREFIX).unwrap(); + + assert_eq!(verified_info.sender, addr); +} + +// Generate a validly signed message but without creating a sign doc first. +#[test] +fn test_verify_no_sign_doc() { + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary("eyJpbnN0YW50aWF0ZV9jb250cmFjdF93aXRoX3NlbGZfYWRtaW4iOnsiY29kZV9pZCI6MTY4OCwiaW5zdGFudGlhdGVfbXNnIjp7fX19ICA=").unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: "juno".to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + let mut deps = mock_dependencies(); + + // Generate a keypair + let secp = Secp256k1::new(); + let (secret_key, public_key) = secp.generate_keypair(&mut OsRng); + + // Hash and sign the payload + let msg_hash = Sha256::digest(&to_binary(&payload).unwrap()); + let msg = Message::from_slice(&msg_hash).unwrap(); + let sig = secp.sign_ecdsa(&msg, &secret_key); + + // Wrap the message + let hex_encoded = HexBinary::from(public_key.serialize_uncompressed()); + let wrapped_msg = WrappedMessage { + payload, + signature: sig.serialize_compact().into(), + public_key: hex_encoded.clone(), + }; + + // Verify should fail + let env = mock_env(); + let info = mock_info("creator", &[]); + let res = verify::( + &deps.api, + &mut deps.storage, + &env, + info, + wrapped_msg.clone(), + ); + assert!(matches!(res, Err(ContractError::SignatureInvalid { .. }))); +} + +/* +Moar tests to write: +wrong version +load a keypair corresponding to pre-known address and validate that address in info was set correctly +test integrating with another contract +wrong contract address +deserialize message into wrong type +*/ diff --git a/contracts/external/cw-verifier-middleware/src/utils.rs b/contracts/external/cw-verifier-middleware/src/utils.rs new file mode 100644 index 000000000..8f1655959 --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/utils.rs @@ -0,0 +1,39 @@ +use cosmwasm_std::{to_binary, Api, HexBinary}; +use secp256k1::{hashes::hex::ToHex, rand::rngs::OsRng, Message, Secp256k1}; +use sha2::{Digest, Sha256}; + +use crate::{ + msg::{Payload, WrappedMessage}, + verify::{get_sign_doc, pk_to_addr}, +}; + +// signs a given payload and returns the wrapped message +pub fn get_wrapped_msg(api: &dyn Api, payload: Payload) -> WrappedMessage { + // Generate a keypair + let secp = Secp256k1::new(); + let (secret_key, public_key) = secp.generate_keypair(&mut OsRng); + + // Generate signdoc + let signer_addr = pk_to_addr( + api, + public_key.to_hex(), // to_hex ensures that the public key has the expected number of bytes + &payload.bech32_prefix, + ) + .unwrap(); + + let payload_ser = serde_json::to_string(&payload).unwrap(); + let sign_doc = get_sign_doc(signer_addr.as_str(), &payload_ser, &"juno-1").unwrap(); + + // Hash and sign the payload + let msg_hash = Sha256::digest(&to_binary(&sign_doc).unwrap()); + let msg = Message::from_slice(&msg_hash).unwrap(); + let sig = secp.sign_ecdsa(&msg, &secret_key); + + // Wrap the message + let hex_encoded = HexBinary::from(public_key.serialize_uncompressed()); + WrappedMessage { + payload, + signature: sig.serialize_compact().into(), + public_key: hex_encoded.clone(), + } +} diff --git a/contracts/external/cw-verifier-middleware/src/verify.rs b/contracts/external/cw-verifier-middleware/src/verify.rs new file mode 100644 index 000000000..4acb9ea2a --- /dev/null +++ b/contracts/external/cw-verifier-middleware/src/verify.rs @@ -0,0 +1,178 @@ +use crate::{ + error::ContractError, + msg::WrappedMessage, + state::{CONTRACT_ADDRESS, NONCES}, +}; +use bech32::{ToBase32, Variant}; +use cosmwasm_schema::{schemars::_serde_json::json, serde::de::DeserializeOwned}; +use cosmwasm_std::{ + from_slice, to_binary, Addr, Api, DepsMut, Env, MessageInfo, StdError, Storage, Uint128, +}; + +use ripemd::Ripemd160; +use sha2::{Digest, Sha256}; + +const UNCOMPRESSED_HEX_PK_LEN: usize = 130; +const COMPRESSED_HEX_PK_LEN: usize = 66; + +pub fn verify( + api: &dyn Api, + storage: &mut dyn Storage, + env: &Env, + info: MessageInfo, + wrapped_msg: WrappedMessage, +) -> Result<(T, MessageInfo), ContractError> +where + T: DeserializeOwned, +{ + let payload = wrapped_msg.payload; + + let signer_addr = pk_to_addr( + api, + wrapped_msg.public_key.to_hex(), // to_hex ensures that the public key has the expected number of bytes + &payload.bech32_prefix, + )?; + + let payload_ser = serde_json::to_string(&payload)?; + + // Convert message to signDoc format + let sign_doc = get_sign_doc(&signer_addr.as_str(), &payload_ser, &payload.chain_id)?; + + // Serialize the payload + let msg_ser = to_binary(&sign_doc)?; + + // Hash the serialized payload using SHA-256 + let msg_hash = Sha256::digest(&msg_ser); + + // Verify the signature + let sig_valid = api.secp256k1_verify( + msg_hash.as_slice(), + &wrapped_msg.signature, + wrapped_msg.public_key.as_slice(), + )?; + + if !sig_valid { + return Err(ContractError::SignatureInvalid {}); + } + + // Validate that the message has not expired + if let Some(expiration) = payload.expiration { + if expiration.is_expired(&env.block) { + return Err(ContractError::MessageExpired {}); + } + } + + let validated_contract_addr = api.addr_validate(&payload.contract_address)?; + let pk = wrapped_msg.public_key.to_hex(); + let nonce_key = ( + pk.as_str(), + &validated_contract_addr, + payload.contract_version.as_str(), + ); + + // Validate that the message has the correct nonce + let nonce = NONCES + .may_load(storage, nonce_key)? + .unwrap_or(Uint128::from(0u128)); + + if payload.nonce != nonce { + return Err(ContractError::InvalidNonce {}); + } + + // Increment nonce + NONCES.update(storage, nonce_key, |nonce: Option| { + nonce + .unwrap_or(Uint128::from(0u128)) + .checked_add(Uint128::from(1u128)) + .map_err(|e| StdError::from(e)) + })?; + + // Construct a new MessageInfo with the signer as the sender + let verified_info = MessageInfo { + sender: signer_addr, + funds: info.funds, + }; + + // Deserialize message into expected type + let verified_msg = from_slice::(&payload.msg.to_vec())?; + + // Return info with sender and deserialized msg + return Ok((verified_msg, verified_info)); +} + +pub fn initialize_contract_addr(deps: DepsMut, env: &Env) -> Result<(), ContractError> { + CONTRACT_ADDRESS.save(deps.storage, &env.contract.address.to_string())?; + Ok(()) +} + +// Takes an compressed or uncompressed hex-encoded EC public key and a bech32 prefix and derives the bech32 address. +pub fn pk_to_addr(api: &dyn Api, hex_pk: String, prefix: &str) -> Result { + // Decode PK from hex + let raw_pk = hex::decode(&hex_pk)?; + + let raw_pk: Vec = match hex_pk.len() { + COMPRESSED_HEX_PK_LEN => Ok::, ContractError>(raw_pk), + UNCOMPRESSED_HEX_PK_LEN => { + let public_key = secp256k1::PublicKey::from_slice(raw_pk.as_slice())?; + // serialize will convert pk to compressed format + Ok(public_key.serialize().to_vec()) + } + _ => { + return Err(ContractError::InvalidPublicKeyLength { + length: hex_pk.len(), + }) + } + }?; + + // sha256 hash the raw public key + let pk_sha256 = Sha256::digest(raw_pk); + + // Take the ripemd160 of the sha256 of the raw pk + let address_raw = Ripemd160::digest(pk_sha256); + + // Encode the prefix and the raw address bytes with bech32 + let bech32 = bech32::encode(&prefix, address_raw.to_base32(), Variant::Bech32)?; + + // Return validated addr + Ok(api.addr_validate(&bech32)?) +} + +use serde_json; + +pub fn get_sign_doc(signer: &str, message: &str, chain_id: &str) -> Result { + let doc = json!({ + "account_number": "0", + "chain_id": chain_id, + "fee": { + "amount": [], + "gas": "0" + }, + "memo": "", + "msgs": [ + { + "type": "cw-verifier", + "value": { + "data": message, + "signer": signer + } + } + ], + "sequence": "0" + }); + + Ok(serde_json::to_string(&doc)?) +} + +pub fn execute_submit_externally_signed< + T: DeserializeOwned, + E: Error + From, +>( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: WrappedMessage, + execute: fn(DepsMut, Env, MessageInfo, T) -> Result, +) -> Result { + let (msg, info): (T, _) = verify(deps.api, deps.storage, env, info, msg)?; + execute(deps, env, info, msg) +} diff --git a/packages/dao-testing/src/helpers.rs b/packages/dao-testing/src/helpers.rs index 8984588a4..d773270e1 100644 --- a/packages/dao-testing/src/helpers.rs +++ b/packages/dao-testing/src/helpers.rs @@ -1,4 +1,4 @@ -use cosmwasm_std::{to_binary, Addr, Binary, Empty, Uint128}; +use cosmwasm_std::{to_binary, Addr, Api, Binary, DepsMut, Empty, HexBinary, Uint128}; use cw20::Cw20Coin; use cw_multi_test::{App, Contract, ContractWrapper, Executor}; use cw_utils::Duration; diff --git a/test-contracts/verifier-test/Cargo.toml b/test-contracts/verifier-test/Cargo.toml new file mode 100644 index 000000000..bf9ae3285 --- /dev/null +++ b/test-contracts/verifier-test/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "verifier-test" +version = "0.1.0" +authors = ["bluenote"] +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +# for more explicit tests, cargo test --features=backtraces +backtraces = ["cosmwasm-std/backtraces"] +# use library feature to disable all instantiate/execute/query exports +library = [] + +[dependencies] +cosmwasm-std = { workspace = true } +cosmwasm-schema = { workspace = true } +cosmwasm-storage = { workspace = true } +cw-storage-plus = { workspace = true } +cw2 = { workspace = true } +thiserror = { workspace = true } +cw-verifier-middleware = {workspace = true} +bincode = "1.3" + +[dev-dependencies] +cw-multi-test = { workspace = true } +dao-testing = { workspace = true} diff --git a/test-contracts/verifier-test/src/contract.rs b/test-contracts/verifier-test/src/contract.rs new file mode 100644 index 000000000..6ed7fb14c --- /dev/null +++ b/test-contracts/verifier-test/src/contract.rs @@ -0,0 +1,51 @@ +use crate::{ + error::ContractError, + msg::{ExecuteMsg, InnerExecuteMsg, InstantiateMsg, QueryMsg}, +}; +#[cfg(not(feature = "library"))] +use cosmwasm_std::entry_point; +use cosmwasm_std::{Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult}; +use cw2::set_contract_version; +use cw_verifier_middleware::verify::verify; + +const CONTRACT_NAME: &str = "crates.io:cw-verifier-test"; +const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn instantiate( + deps: DepsMut, + _env: Env, + _info: MessageInfo, + _msg: InstantiateMsg, +) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::new().add_attribute("method", "instantiate")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn execute( + deps: DepsMut, + env: Env, + info: MessageInfo, + msg: ExecuteMsg, +) -> Result { + let (verified_msg, verified_info) = + verify::(deps.api, deps.storage, &env, info, msg.wrapped_msg)?; + match verified_msg { + InnerExecuteMsg::Execute => execute_execute(deps, env, verified_info)?, + }; + Ok(Response::default()) +} + +pub fn execute_execute( + _deps: DepsMut, + _env: Env, + _info: MessageInfo, +) -> Result { + Ok(Response::default().add_attribute("action", "execute_execute")) +} + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn query(_deps: Deps, _env: Env, _msg: QueryMsg) -> StdResult { + Ok(Binary::default()) +} diff --git a/test-contracts/verifier-test/src/error.rs b/test-contracts/verifier-test/src/error.rs new file mode 100644 index 000000000..74726833f --- /dev/null +++ b/test-contracts/verifier-test/src/error.rs @@ -0,0 +1,15 @@ +use cosmwasm_std::StdError; +use cw_verifier_middleware::error::ContractError as VerifyError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ContractError { + #[error("{0}")] + Std(#[from] StdError), + + #[error("{0}")] + VerifyError(#[from] VerifyError), + + #[error("Unauthorized")] + Unauthorized {}, +} diff --git a/test-contracts/verifier-test/src/lib.rs b/test-contracts/verifier-test/src/lib.rs new file mode 100644 index 000000000..2ed82bd3f --- /dev/null +++ b/test-contracts/verifier-test/src/lib.rs @@ -0,0 +1,5 @@ +pub mod contract; +pub mod error; +pub mod msg; +#[cfg(test)] +mod tests; diff --git a/test-contracts/verifier-test/src/msg.rs b/test-contracts/verifier-test/src/msg.rs new file mode 100644 index 000000000..ae55a9f7f --- /dev/null +++ b/test-contracts/verifier-test/src/msg.rs @@ -0,0 +1,21 @@ +use cosmwasm_schema::{cw_serde, QueryResponses}; +use cosmwasm_std::CosmosMsg; +use cw_verifier_middleware::msg::WrappedMessage; + +#[cw_serde] +pub struct InstantiateMsg {} + +#[cw_serde] +pub enum InnerExecuteMsg { + Execute, +} + +#[cw_serde] + +pub struct ExecuteMsg { + pub wrapped_msg: WrappedMessage, +} + +#[cw_serde] + +pub struct QueryMsg {} diff --git a/test-contracts/verifier-test/src/tests.rs b/test-contracts/verifier-test/src/tests.rs new file mode 100644 index 000000000..6d044696a --- /dev/null +++ b/test-contracts/verifier-test/src/tests.rs @@ -0,0 +1,68 @@ +use cosmwasm_std::testing::{MockApi, MockStorage}; +use cosmwasm_std::{to_binary, Addr, Api, Empty, Storage, Uint128}; +use cw_multi_test::{AppBuilder, Executor, Router}; +use cw_multi_test::{Contract, ContractWrapper}; +use cw_verifier_middleware::msg::Payload; +use cw_verifier_middleware::utils::get_wrapped_msg; + +use crate::msg::{ExecuteMsg, InnerExecuteMsg}; + +fn test_contract() -> Box> { + let contract = ContractWrapper::new( + crate::contract::execute, + crate::contract::instantiate, + crate::contract::query, + ); + Box::new(contract) +} + +fn no_init( + _: &mut Router, + _: &dyn Api, + _: &mut dyn Storage, +) { +} + +#[test] +fn test_verify() { + let api = MockApi::default(); + let storage = MockStorage::new(); + + let mut app = AppBuilder::new() + .with_api(api) + .with_storage(storage) + .build(no_init); + + let code_id = app.store_code(test_contract()); + let contract = app + .instantiate_contract( + code_id, + Addr::unchecked("admin"), + &crate::msg::InstantiateMsg {}, + &[], + "test contract", + None, + ) + .unwrap(); + + let payload = Payload { + nonce: Uint128::from(0u128), + msg: to_binary(&InnerExecuteMsg::Execute {}).unwrap(), + expiration: None, + contract_address: Addr::unchecked("contract_address").to_string(), + bech32_prefix: "juno".to_string(), + contract_version: "version-1".to_string(), + chain_id: "juno-1".to_string(), + }; + + let wrapped_msg = get_wrapped_msg(&api, payload); + app.execute_contract( + Addr::unchecked("ADMIN"), + contract, + &ExecuteMsg { + wrapped_msg: wrapped_msg, + }, + &[], + ) + .unwrap(); +}