diff --git a/Cargo.lock b/Cargo.lock index 920cc8c64..4c1d04c6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -755,6 +755,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.6.0" @@ -1515,6 +1521,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-targets 0.52.6", ] @@ -2058,6 +2065,41 @@ dependencies = [ "syn 2.0.85", ] +[[package]] +name = "darling" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.85", +] + +[[package]] +name = "darling_macro" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.85", +] + [[package]] name = "data-encoding" version = "2.6.0" @@ -3638,6 +3680,12 @@ dependencies = [ "cc", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.2.3" @@ -5792,6 +5840,7 @@ dependencies = [ "scale-info", "serde", "serde_json", + "serde_with", "sp-application-crypto", "sp-core", "sp-io", @@ -9045,6 +9094,34 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.85", +] + [[package]] name = "sha-1" version = "0.9.8" diff --git a/pallets/ddc-verification/Cargo.toml b/pallets/ddc-verification/Cargo.toml index f96474491..39adbc056 100644 --- a/pallets/ddc-verification/Cargo.toml +++ b/pallets/ddc-verification/Cargo.toml @@ -29,6 +29,7 @@ rand = { workspace = true, features = ["small_rng", "alloc"], default-features = scale-info = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_with = { version = "3", default-features = false, features = ["base64", "macros"] } sp-application-crypto = { workspace = true } sp-core = { workspace = true } sp-io = { workspace = true } diff --git a/pallets/ddc-verification/src/aggregator_client.rs b/pallets/ddc-verification/src/aggregator_client.rs index c7c14b00c..f60f9207a 100644 --- a/pallets/ddc-verification/src/aggregator_client.rs +++ b/pallets/ddc-verification/src/aggregator_client.rs @@ -2,20 +2,23 @@ use ddc_primitives::{AggregatorInfo, BucketId, DdcEra}; use prost::Message; +use serde_with::{base64::Base64, serde_as}; use sp_io::offchain::timestamp; use sp_runtime::offchain::{http, Duration}; use super::*; +use crate::signature::Verify; pub struct AggregatorClient<'a> { pub base_url: &'a str, timeout: Duration, retries: u32, + verify_sig: bool, } impl<'a> AggregatorClient<'a> { - pub fn new(base_url: &'a str, timeout: Duration, retries: u32) -> Self { - Self { base_url, timeout, retries } + pub fn new(base_url: &'a str, timeout: Duration, retries: u32, verify_sig: bool) -> Self { + Self { base_url, timeout, retries, verify_sig } } pub fn buckets_aggregates( @@ -31,11 +34,28 @@ impl<'a> AggregatorClient<'a> { if let Some(prev_token) = prev_token { url = format!("{}&prevToken={}", url, prev_token); } + if self.verify_sig { + url = format!("{}&sign=true", url); + } let response = self.get(&url, Accept::Any)?; + let body = response.body().collect::>(); - let json_response = serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + if self.verify_sig { + let json_response: json::SignedJsonResponse> = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + + if !json_response.verify() { + log::debug!("bad signature, req: {:?}, resp: {:?}", url, json_response); + return Err(http::Error::Unknown); // TODO (khssnv): more specific error. + } + + Ok(json_response.payload) + } else { + let json_response: Vec = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; - Ok(json_response) + Ok(json_response) + } } pub fn nodes_aggregates( @@ -51,11 +71,28 @@ impl<'a> AggregatorClient<'a> { if let Some(prev_token) = prev_token { url = format!("{}&prevToken={}", url, prev_token); } + if self.verify_sig { + url = format!("{}&sign=true", url); + } let response = self.get(&url, Accept::Any)?; + let body = response.body().collect::>(); - let json_response = serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + if self.verify_sig { + let json_response: json::SignedJsonResponse> = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + + if !json_response.verify() { + log::debug!("bad signature, req: {:?}, resp: {:?}", url, json_response); + return Err(http::Error::Unknown); // TODO (khssnv): more specific error. + } - Ok(json_response) + Ok(json_response.payload) + } else { + let json_response: Vec = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + + Ok(json_response) + } } pub fn challenge_bucket_sub_aggregate( @@ -103,12 +140,29 @@ impl<'a> AggregatorClient<'a> { } pub fn eras(&self) -> Result, http::Error> { - let url = format!("{}/activity/eras", self.base_url); + let mut url = format!("{}/activity/eras", self.base_url); + if self.verify_sig { + url = format!("{}&sign=true", url); + } let response = self.get(&url, Accept::Any)?; + let body = response.body().collect::>(); - let json_response = serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + if self.verify_sig { + let json_response: json::SignedJsonResponse> = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; - Ok(json_response) + if !json_response.verify() { + log::debug!("bad signature, req: {:?}, resp: {:?}", url, json_response); + return Err(http::Error::Unknown); // TODO (khssnv): more specific error. + } + + Ok(json_response.payload) + } else { + let json_response: Vec = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + + Ok(json_response) + } } pub fn traverse_bucket_sub_aggregate( @@ -119,16 +173,33 @@ impl<'a> AggregatorClient<'a> { merkle_tree_node_id: u32, levels: u16, ) -> Result { - let url = format!( + let mut url = format!( "{}/activity/buckets/{}/traverse?eraId={}&nodeId={}&merkleTreeNodeId={}&levels={}", self.base_url, bucket_id, era_id, node_id, merkle_tree_node_id, levels, ); + if self.verify_sig { + url = format!("{}&sign=true", url); + } let response = self.get(&url, Accept::Any)?; + let body = response.body().collect::>(); - let json_response = serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + if self.verify_sig { + let json_response: json::SignedJsonResponse = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + + if !json_response.verify() { + log::debug!("bad signature, req: {:?}, resp: {:?}", url, json_response); + return Err(http::Error::Unknown); // TODO (khssnv): more specific error. + } + + Ok(json_response.payload) + } else { + let json_response: json::MerkleTreeNodeResponse = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; - Ok(json_response) + Ok(json_response) + } } pub fn traverse_node_aggregate( @@ -138,16 +209,32 @@ impl<'a> AggregatorClient<'a> { merkle_tree_node_id: u32, levels: u16, ) -> Result { - let url = format!( + let mut url = format!( "{}/activity/nodes/{}/traverse?eraId={}&merkleTreeNodeId={}&levels={}", self.base_url, node_id, era_id, merkle_tree_node_id, levels, ); - + if self.verify_sig { + url = format!("{}&sign=true", url); + } let response = self.get(&url, Accept::Any)?; + let body = response.body().collect::>(); - let json_response = serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + if self.verify_sig { + let json_response: json::SignedJsonResponse = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; - Ok(json_response) + if !json_response.verify() { + log::debug!("bad signature, req: {:?}, resp: {:?}", url, json_response); + return Err(http::Error::Unknown); // TODO (khssnv): more specific error. + } + + Ok(json_response.payload) + } else { + let json_response: json::MerkleTreeNodeResponse = + serde_json::from_slice(&body).map_err(|_| http::Error::Unknown)?; + + Ok(json_response) + } } fn merkle_tree_node_id_param(merkle_tree_node_id: &[u32]) -> String { @@ -430,4 +517,17 @@ pub(crate) mod json { pub number_of_puts: u64, pub number_of_gets: u64, } + + /// Json response wrapped with a signature. + #[serde_as] + #[derive( + Debug, Serialize, Deserialize, Clone, Hash, Ord, PartialOrd, PartialEq, Eq, Encode, Decode, + )] + pub struct SignedJsonResponse { + pub payload: T, + #[serde_as(as = "Base64")] + pub signer: Vec, + #[serde_as(as = "Base64")] + pub signature: Vec, + } } diff --git a/pallets/ddc-verification/src/lib.rs b/pallets/ddc-verification/src/lib.rs index b17a3724a..56a8551e4 100644 --- a/pallets/ddc-verification/src/lib.rs +++ b/pallets/ddc-verification/src/lib.rs @@ -163,6 +163,7 @@ pub mod pallet { type AccountIdConverter: From + Into; type CustomerVisitor: CustomerVisitor; type Currency: Currency; + const VERIFY_AGGREGATOR_RESPONSE_SIGNATURE: bool; #[cfg(feature = "runtime-benchmarks")] type CustomerDepositor: CustomerDepositor; #[cfg(feature = "runtime-benchmarks")] @@ -2982,6 +2983,7 @@ pub mod pallet { &base_url, Duration::from_millis(RESPONSE_TIMEOUT), 3, + T::VERIFY_AGGREGATOR_RESPONSE_SIGNATURE, ); match aggregate_key { @@ -3018,6 +3020,7 @@ pub mod pallet { &base_url, Duration::from_millis(RESPONSE_TIMEOUT), 3, + T::VERIFY_AGGREGATOR_RESPONSE_SIGNATURE, ); let response = match aggregate_key { @@ -3050,6 +3053,7 @@ pub mod pallet { &base_url, Duration::from_millis(RESPONSE_TIMEOUT), 3, + T::VERIFY_AGGREGATOR_RESPONSE_SIGNATURE, ); let response = client.eras()?; @@ -3073,6 +3077,7 @@ pub mod pallet { &base_url, Duration::from_millis(RESPONSE_TIMEOUT), 3, + T::VERIFY_AGGREGATOR_RESPONSE_SIGNATURE, ); let mut buckets_aggregates = Vec::new(); @@ -3115,6 +3120,7 @@ pub mod pallet { &base_url, Duration::from_millis(RESPONSE_TIMEOUT), 3, + T::VERIFY_AGGREGATOR_RESPONSE_SIGNATURE, ); let mut nodes_aggregates = Vec::new(); diff --git a/pallets/ddc-verification/src/mock.rs b/pallets/ddc-verification/src/mock.rs index 677d2c3ad..64fa0de52 100644 --- a/pallets/ddc-verification/src/mock.rs +++ b/pallets/ddc-verification/src/mock.rs @@ -220,6 +220,7 @@ impl pallet_timestamp::Config for Test { parameter_types! { pub const VerificationPalletId: PalletId = PalletId(*b"verifypa"); pub const MajorityOfAggregators: Percent = Percent::from_percent(67); + pub const VerifyAggregatorResponseSignature: bool = false; } impl crate::Config for Test { @@ -245,6 +246,7 @@ impl crate::Config for Test { type CustomerVisitor = MockCustomerVisitor; const MAX_MERKLE_NODE_IDENTIFIER: u16 = 4; type Currency = Balances; + const VERIFY_AGGREGATOR_RESPONSE_SIGNATURE: bool = false; #[cfg(feature = "runtime-benchmarks")] type CustomerDepositor = MockCustomerDepositor; #[cfg(feature = "runtime-benchmarks")] diff --git a/pallets/ddc-verification/src/signature.rs b/pallets/ddc-verification/src/signature.rs index b65b79fa9..2069666b4 100644 --- a/pallets/ddc-verification/src/signature.rs +++ b/pallets/ddc-verification/src/signature.rs @@ -1,3 +1,4 @@ +use aggregator_client::json; use prost::Message; use sp_core::ed25519::{Public, Signature}; use sp_io::crypto::ed25519_verify; @@ -99,6 +100,27 @@ impl Verify for proto::ChallengeResponse { } } +impl Verify for json::SignedJsonResponse { + fn verify(&self) -> bool { + let sig = match Signature::try_from(self.signature.as_slice()) { + Ok(s) => s, + Err(_) => return false, + }; + + let payload = match serde_json::to_vec(&self.payload) { + Ok(p) => p, + Err(_) => return false, + }; + + let pub_key = match Public::try_from(self.signer.as_slice()) { + Ok(p) => p, + Err(_) => return false, + }; + + ed25519_verify(&sig, payload.as_slice(), &pub_key) + } +} + trait Signed { fn get_signature(&self) -> Option<&proto::Signature>; fn reset_signature(&mut self); diff --git a/pallets/ddc-verification/src/test_data/activity_buckets_signed_resp.json b/pallets/ddc-verification/src/test_data/activity_buckets_signed_resp.json new file mode 100644 index 000000000..d5d007d3d --- /dev/null +++ b/pallets/ddc-verification/src/test_data/activity_buckets_signed_resp.json @@ -0,0 +1,36 @@ +{ + "payload": [ + { + "bucket_id": 1, + "stored_bytes": 3145728, + "transferred_bytes": 6291456, + "number_of_puts": 3, + "number_of_gets": 3, + "sub_aggregates": [ + { + "NodeID": "0x0ac7cb9c53594e9f538d9950c6bcf28f0c0c7b8385deea2ebe24062bc640e7be", + "stored_bytes": 3145728, + "transferred_bytes": 4194304, + "number_of_puts": 3, + "number_of_gets": 1 + }, + { + "NodeID": "0x7adf9ca8b587d3b2184d628baac385b43a247b981a66e20ecc89e15a7a273ca5", + "stored_bytes": 0, + "transferred_bytes": 1048576, + "number_of_puts": 0, + "number_of_gets": 1 + }, + { + "NodeID": "0xc4751f725bf42932f20b3d4d8ec2c074cac405f29d96d6b5df45a2101bc1d78b", + "stored_bytes": 0, + "transferred_bytes": 1048576, + "number_of_puts": 0, + "number_of_gets": 1 + } + ] + } + ], + "signer": "xHUfclv0KTLyCz1NjsLAdMrEBfKdlta130WiEBvB14s=", + "signature": "XCyDGX+MRqpbRUng54h3UJ5rklshT92FTYfKF2WLR81Aovf4ZpqqoObOoBn7+g2XXf8vX+RY3dXceBipJ8M6CA==" +} diff --git a/pallets/ddc-verification/src/tests.rs b/pallets/ddc-verification/src/tests.rs index a308d76f9..ef31aa139 100644 --- a/pallets/ddc-verification/src/tests.rs +++ b/pallets/ddc-verification/src/tests.rs @@ -3097,7 +3097,49 @@ fn challenge_bucket_sub_aggregate_works() { }); } -use crate::aggregator_client::AggregatorClient; +use crate::aggregator_client::{ + json::{BucketAggregateResponse, SignedJsonResponse}, + AggregatorClient, +}; + +#[test] +fn aggregator_client_get_buckets_aggregates_works() { + let mut ext = TestExternalities::default(); + let (offchain, offchain_state) = TestOffchainExt::new(); + + ext.register_extension(OffchainWorkerExt::new(offchain.clone())); + ext.register_extension(OffchainDbExt::new(Box::new(offchain))); + + ext.execute_with(|| { + let mut offchain_state = offchain_state.write(); + offchain_state.timestamp = Timestamp::from_unix_millis(0); + + let base_url = "http://example.com:8080"; + let era_id = 346524624; + let activity_buckets_signed_resp = + include_bytes!("./test_data/activity_buckets_signed_resp.json").as_slice(); + + let expected_request = PendingRequest { + method: "GET".to_string(), + uri: format!("{}/activity/buckets?eraId={}&sign=true", base_url, era_id), + response: Some(activity_buckets_signed_resp.to_vec()), + sent: true, + ..Default::default() + }; + + offchain_state.expect_request(expected_request); + drop(offchain_state); + + let client = AggregatorClient::new(base_url, Duration::from_millis(1_000), 1, true); + + let expected_response: SignedJsonResponse> = + serde_json::from_slice(activity_buckets_signed_resp) + .expect("json parsing failed, broken test data?"); + let result = client.buckets_aggregates(era_id, None, None); + + assert_eq!(result, Ok(expected_response.payload)); + }) +} #[test] fn aggregator_client_challenge_bucket_sub_aggregate_works() { @@ -3148,7 +3190,7 @@ fn aggregator_client_challenge_bucket_sub_aggregate_works() { offchain_state.expect_request(expected); drop(offchain_state); - let client = AggregatorClient::new(base_url, Duration::from_millis(1_000), 1); + let client = AggregatorClient::new(base_url, Duration::from_millis(1_000), 1, false); let result = client.challenge_bucket_sub_aggregate(era_id, bucket_id, node_id, vec![2, 6]); assert_eq!(result, Ok(expected_response)); @@ -3203,7 +3245,7 @@ fn aggregator_client_challenge_node_aggregate_works() { offchain_state.expect_request(expected); drop(offchain_state); - let client = AggregatorClient::new(base_url, Duration::from_millis(1_000), 1); + let client = AggregatorClient::new(base_url, Duration::from_millis(1_000), 1, false); let result = client.challenge_node_aggregate(era_id, node_id, vec![2, 6]); assert_eq!(result, Ok(expected_response)); diff --git a/runtime/cere-dev/src/lib.rs b/runtime/cere-dev/src/lib.rs index 4606fc962..1f723ae65 100644 --- a/runtime/cere-dev/src/lib.rs +++ b/runtime/cere-dev/src/lib.rs @@ -1328,6 +1328,7 @@ impl pallet_ddc_verification::Config for Runtime { type CustomerVisitor = pallet_ddc_customers::Pallet; const MAX_MERKLE_NODE_IDENTIFIER: u16 = 3; type Currency = Balances; + const VERIFY_AGGREGATOR_RESPONSE_SIGNATURE: bool = true; #[cfg(feature = "runtime-benchmarks")] type CustomerDepositor = DdcCustomers; #[cfg(feature = "runtime-benchmarks")] diff --git a/runtime/cere/src/lib.rs b/runtime/cere/src/lib.rs index e6c952547..e03bbc17a 100644 --- a/runtime/cere/src/lib.rs +++ b/runtime/cere/src/lib.rs @@ -1348,6 +1348,7 @@ impl pallet_ddc_verification::Config for Runtime { type CustomerVisitor = pallet_ddc_customers::Pallet; const MAX_MERKLE_NODE_IDENTIFIER: u16 = 3; type Currency = Balances; + const VERIFY_AGGREGATOR_RESPONSE_SIGNATURE: bool = true; #[cfg(feature = "runtime-benchmarks")] type CustomerDepositor = DdcCustomers; #[cfg(feature = "runtime-benchmarks")]