From dc9b1cec5524f6d2c4b56e54ad2b1fc42ff190c3 Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Tue, 26 Sep 2023 01:29:41 +0300 Subject: [PATCH 01/10] feat: verify particle signatures --- Cargo.lock | 1 + aquamarine/src/error.rs | 11 +++++++++++ aquamarine/src/plumber.rs | 10 ++++++++++ crates/local-vm/src/local_vm.rs | 8 ++++++-- crates/nox-tests/tests/local_vm.rs | 2 +- nox/src/node.rs | 9 +++++++-- particle-protocol/src/particle.rs | 2 -- script-storage/Cargo.toml | 1 + script-storage/src/script_storage.rs | 14 ++++++++++++-- 9 files changed, 49 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 45247e67f6..b776631f01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6092,6 +6092,7 @@ dependencies = [ "async-unlock", "chrono", "connection-pool", + "fluence-keypair", "fluence-libp2p", "futures", "log", diff --git a/aquamarine/src/error.rs b/aquamarine/src/error.rs index e7c4be0aeb..abe0920a62 100644 --- a/aquamarine/src/error.rs +++ b/aquamarine/src/error.rs @@ -15,6 +15,7 @@ */ use humantime::FormattedDuration; +use particle_protocol::ParticleError; use thiserror::Error; #[derive(Debug, Error)] @@ -44,6 +45,11 @@ pub enum AquamarineApiError { "AquamarineApiError::AquamarineQueueFull: can't send particle {particle_id:?} to Aquamarine" )] AquamarineQueueFull { particle_id: Option }, + #[error("AquamarineApiError::SignatureVerificationFailed: particle_id = {particle_id}, error = {err}")] + SignatureVerificationFailed { + particle_id: String, + err: ParticleError, + }, } impl AquamarineApiError { @@ -52,6 +58,11 @@ impl AquamarineApiError { AquamarineApiError::ParticleExpired { particle_id } => Some(particle_id), AquamarineApiError::OneshotCancelled { particle_id } => Some(particle_id), AquamarineApiError::ExecutionTimedOut { particle_id, .. } => Some(particle_id), + // Should it be `None` considering usage of signature as particle id? + // It can compromise valid particles into thinking they are invalid. + // But still there can be a case when signature was generated wrong + // and client will never know about it. + AquamarineApiError::SignatureVerificationFailed { .. } => None, AquamarineApiError::AquamarineDied { particle_id } => particle_id, AquamarineApiError::AquamarineQueueFull { particle_id, .. } => particle_id, } diff --git a/aquamarine/src/plumber.rs b/aquamarine/src/plumber.rs index 843a3ac3da..aa6199af86 100644 --- a/aquamarine/src/plumber.rs +++ b/aquamarine/src/plumber.rs @@ -92,6 +92,16 @@ impl Plumber { return; } + if let Err(err) = particle.verify() { + tracing::info!(target: "signature_failed", particle_id = particle.id, "Particle signature verification failed: {err:?}"); + self.events + .push_back(Err(AquamarineApiError::SignatureVerificationFailed { + particle_id: particle.id, + err, + })); + return; + } + let builtins = &self.builtins; let key = (particle.id.clone(), worker_id); let entry = self.actors.entry(key); diff --git a/crates/local-vm/src/local_vm.rs b/crates/local-vm/src/local_vm.rs index 617c275e89..584ab1143f 100644 --- a/crates/local-vm/src/local_vm.rs +++ b/crates/local-vm/src/local_vm.rs @@ -303,7 +303,7 @@ pub fn make_particle( tracing::info!(particle_id = id, "Made a particle"); - Particle { + let mut particle = Particle { id, init_peer_id: peer_id, timestamp, @@ -311,7 +311,11 @@ pub fn make_particle( script, signature: vec![], data: particle_data, - } + }; + + particle.sign(key_pair).expect("sign particle"); + + particle } pub fn read_args( diff --git a/crates/nox-tests/tests/local_vm.rs b/crates/nox-tests/tests/local_vm.rs index 604615620a..d75f3eb9cb 100644 --- a/crates/nox-tests/tests/local_vm.rs +++ b/crates/nox-tests/tests/local_vm.rs @@ -57,7 +57,7 @@ fn make() { &mut local_vm_a, false, Duration::from_secs(20), - &keypair_b, + &keypair_a, ); let args = read_args(particle, client_b, &mut local_vm_b, &keypair_b) diff --git a/nox/src/node.rs b/nox/src/node.rs index 3e2580855c..4d30531c23 100644 --- a/nox/src/node.rs +++ b/nox/src/node.rs @@ -169,7 +169,7 @@ impl Node { libp2p_metrics.clone(), connectivity_metrics, connection_pool_metrics, - key_pair, + key_pair.clone(), &config, node_version, connection_limits, @@ -196,7 +196,12 @@ impl Node { }; let pool: &ConnectionPoolApi = connectivity.as_ref(); - ScriptStorageBackend::new(pool.clone(), particle_failures_in, script_storage_config) + ScriptStorageBackend::new( + pool.clone(), + particle_failures_in, + script_storage_config, + key_pair.into(), + ) }; let (services_metrics_backend, services_metrics) = diff --git a/particle-protocol/src/particle.rs b/particle-protocol/src/particle.rs index 674752ac74..daad1147ce 100644 --- a/particle-protocol/src/particle.rs +++ b/particle-protocol/src/particle.rs @@ -90,14 +90,12 @@ impl Particle { /// return immutable particle fields in bytes for signing /// concatenation of: /// - id as bytes - /// - init_peer_id in base58 as bytes /// - timestamp u64 as little-endian bytes /// - ttl u32 as little-endian bytes /// - script as bytes fn as_bytes(&self) -> Vec { let mut bytes = vec![]; bytes.extend(self.id.as_bytes()); - bytes.extend(self.init_peer_id.to_base58().as_bytes()); bytes.extend(self.timestamp.to_le_bytes()); bytes.extend(self.ttl.to_le_bytes()); bytes.extend(self.script.as_bytes()); diff --git a/script-storage/Cargo.toml b/script-storage/Cargo.toml index ff9d3b0a0f..6b74ca7d8d 100644 --- a/script-storage/Cargo.toml +++ b/script-storage/Cargo.toml @@ -11,6 +11,7 @@ fluence-libp2p = { workspace = true } async-unlock = { workspace = true } now-millis = { workspace = true } +fluence-keypair = { workspace = true } tokio = { workspace = true } tokio-stream = { workspace = true } diff --git a/script-storage/src/script_storage.rs b/script-storage/src/script_storage.rs index cb0d5f0236..6a8b1c1f80 100644 --- a/script-storage/src/script_storage.rs +++ b/script-storage/src/script_storage.rs @@ -21,6 +21,7 @@ use connection_pool::{ConnectionPoolApi, ConnectionPoolT}; use fluence_libp2p::PeerId; use particle_protocol::{Contact, Particle}; +use fluence_keypair::KeyPair; use futures::{future::BoxFuture, FutureExt, StreamExt, TryFutureExt}; use now_millis::now_ms; use std::{ @@ -136,6 +137,7 @@ pub struct ScriptStorageBackend { failed_particles: mpsc::UnboundedReceiver, connection_pool: ConnectionPoolApi, config: ScriptStorageConfig, + root_keypair: KeyPair, } impl ScriptStorageBackend { @@ -143,6 +145,7 @@ impl ScriptStorageBackend { connection_pool: ConnectionPoolApi, failed_particles: mpsc::UnboundedReceiver, config: ScriptStorageConfig, + root_keypair: KeyPair, ) -> (ScriptStorageApi, Self) { let (outlet, inlet) = mpsc::unbounded_channel(); let api = ScriptStorageApi { outlet }; @@ -153,6 +156,7 @@ impl ScriptStorageBackend { failed_particles, connection_pool, config, + root_keypair, }; (api, this) } @@ -166,6 +170,7 @@ impl ScriptStorageBackend { let pool = self.connection_pool; let config = self.config; let max_failures = self.config.max_failures; + let root_keypair = self.root_keypair; let mut timer = IntervalStream::new(interval(self.config.timer_resolution)); @@ -178,7 +183,7 @@ impl ScriptStorageBackend { remove_failed_scripts(failed, &sent_particles, &scripts, max_failures).await; }, _ = timer.next() => { - execute_scripts(&pool, &scripts, &sent_particles, config).await; + execute_scripts(&pool, &scripts, &sent_particles, config, &root_keypair).await; cleanup(&sent_particles).await; } } @@ -192,6 +197,7 @@ async fn execute_scripts( scripts: &Mutex>, sent_particles: &Mutex>, config: ScriptStorageConfig, + root_keypair: &KeyPair, ) { let now = Instant::now(); let now_u64 = now_ms() as u64; @@ -231,7 +237,7 @@ async fn execute_scripts( .await; // Send particle to the current node - let particle = Particle { + let mut particle = Particle { id: particle_id, init_peer_id: config.peer_id, timestamp: now_u64, @@ -240,6 +246,10 @@ async fn execute_scripts( signature: vec![], data: vec![], }; + + // I don't think we should process errors here, + // because script-storage will be deprecated soon + particle.sign(root_keypair).expect("sign particle"); let contact = Contact::new(config.peer_id, vec![]); pool.send(contact, particle).await; } From 9d28dd8d1dabbaf894fbff6cf2594dddfe236cab Mon Sep 17 00:00:00 2001 From: Aleksey Proshutisnkiy Date: Thu, 28 Sep 2023 14:28:44 +0300 Subject: [PATCH 02/10] pr fix --- aquamarine/src/plumber.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aquamarine/src/plumber.rs b/aquamarine/src/plumber.rs index aa6199af86..ed054b6ce3 100644 --- a/aquamarine/src/plumber.rs +++ b/aquamarine/src/plumber.rs @@ -93,7 +93,7 @@ impl Plumber { } if let Err(err) = particle.verify() { - tracing::info!(target: "signature_failed", particle_id = particle.id, "Particle signature verification failed: {err:?}"); + tracing::warn!(target: "signature", particle_id = particle.id, "Particle signature verification failed: {err:?}"); self.events .push_back(Err(AquamarineApiError::SignatureVerificationFailed { particle_id: particle.id, From bbe5600540b4061a6a8b3c4dad1273485a838ae3 Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Mon, 9 Oct 2023 18:04:43 +0200 Subject: [PATCH 03/10] add particle signature test --- particle-protocol/src/particle.rs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/particle-protocol/src/particle.rs b/particle-protocol/src/particle.rs index daad1147ce..1c4b3e4aa4 100644 --- a/particle-protocol/src/particle.rs +++ b/particle-protocol/src/particle.rs @@ -157,3 +157,34 @@ impl std::fmt::Display for Particle { ) } } + +#[cfg(test)] +mod tests { + use crate::Particle; + use base64::{engine::general_purpose::STANDARD as base64, Engine}; + use fluence_keypair::{KeyFormat, KeyPair}; + + #[test] + fn test_signature() { + let kp_bytes = base64 + .decode("7h48PQ/f1rS9TxacmgODxbD42Il9B3KC117jvOPppPE=") + .unwrap(); + assert_eq!(kp_bytes.len(), 32); + + let kp = KeyPair::from_secret_key(kp_bytes, KeyFormat::Ed25519).unwrap(); + + let mut p = Particle { + id: "abc".to_string(), + init_peer_id: kp.get_peer_id(), + timestamp: 12345, + ttl: 34, + script: "(abc)".to_string(), + signature: vec![], + data: vec![], + }; + + p.sign(&kp).unwrap(); + assert!(p.verify().is_ok()); + assert_eq!(base64.encode(&p.signature), "yXWURARFMrIwJ0VwDFj5bvaYcODyyRWxLLd+v8JxORnIFDItJW5268JL+S009ENsGuFf6sXhCCdQkeO4tdDrCw==") + } +} From b659baaa13100c7ef3ea2b7cdd67b4f451f1dc11 Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Mon, 9 Oct 2023 18:39:36 +0200 Subject: [PATCH 04/10] update test --- particle-protocol/src/particle.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/particle-protocol/src/particle.rs b/particle-protocol/src/particle.rs index 1c4b3e4aa4..4b6bd9c32e 100644 --- a/particle-protocol/src/particle.rs +++ b/particle-protocol/src/particle.rs @@ -173,6 +173,20 @@ mod tests { let kp = KeyPair::from_secret_key(kp_bytes, KeyFormat::Ed25519).unwrap(); + // assert peer id + assert_eq!( + kp.get_peer_id().to_base58(), + "12D3KooWANqfCDrV79MZdMnMqTvDdqSAPSxdgFY1L6DCq2DVGB4D" + ); + + // test simple signature + let message = "message".to_string(); + + let signature = kp.sign(message.as_bytes()).unwrap(); + assert!(kp.public().verify(message.as_bytes(), &signature).is_ok()); + assert_eq!(base64.encode(signature.to_vec()), "sBW7H6/1fwAwF86ldwVm9BDu0YH3w30oFQjTWX0Tiu9yTVZHmxkV2OX4GL5jn0Iz0CrasGcOfozzkZwtJBPMBg=="); + + // test particle signature let mut p = Particle { id: "abc".to_string(), init_peer_id: kp.get_peer_id(), From 2e8317a3b3037a1710d4c3bf0abe2a7be75bf5c4 Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Tue, 10 Oct 2023 12:24:24 +0200 Subject: [PATCH 05/10] add logs --- particle-protocol/src/error.rs | 6 ++++-- particle-protocol/src/particle.rs | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/particle-protocol/src/error.rs b/particle-protocol/src/error.rs index 71cd5d7131..a21260af86 100644 --- a/particle-protocol/src/error.rs +++ b/particle-protocol/src/error.rs @@ -31,11 +31,13 @@ pub enum ParticleError { err: SigningError, particle_id: String, }, - #[error("Failed to verify particle {particle_id} signature: {err}")] + #[error("Failed to verify particle {particle} by {peer_id} with signature: {err}")] SignatureVerificationFailed { #[source] err: VerificationError, - particle_id: String, + // particle_id: String, + peer_id: String, + particle: String, }, #[error("Failed to decode public key from init_peer_id of particle {particle_id}: {err}")] DecodingError { diff --git a/particle-protocol/src/particle.rs b/particle-protocol/src/particle.rs index 4b6bd9c32e..6f943d6ffd 100644 --- a/particle-protocol/src/particle.rs +++ b/particle-protocol/src/particle.rs @@ -132,7 +132,8 @@ impl Particle { pk.verify(&self.as_bytes(), &sig) .map_err(|err| SignatureVerificationFailed { err, - particle_id: self.id.clone(), + particle: serde_json::to_string::(&self).unwrap(), + peer_id: self.init_peer_id.to_base58(), }) } } @@ -199,6 +200,6 @@ mod tests { p.sign(&kp).unwrap(); assert!(p.verify().is_ok()); - assert_eq!(base64.encode(&p.signature), "yXWURARFMrIwJ0VwDFj5bvaYcODyyRWxLLd+v8JxORnIFDItJW5268JL+S009ENsGuFf6sXhCCdQkeO4tdDrCw==") + assert_eq!(base64.encode(&p.signature), "yXWURARFMrIwJ0VwDFj5bvaYcODyyRWxLLd+v8JxORnIFDItJW5268JL+S009ENsGuFf6sXhCCdQkeO4tdDrCw=="); } } From ee1d7adc265da2698593ad9b1e01cb835c4c711d Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Tue, 10 Oct 2023 13:33:38 +0200 Subject: [PATCH 06/10] update test --- particle-protocol/src/particle.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/particle-protocol/src/particle.rs b/particle-protocol/src/particle.rs index 6f943d6ffd..83a7b7da87 100644 --- a/particle-protocol/src/particle.rs +++ b/particle-protocol/src/particle.rs @@ -164,6 +164,8 @@ mod tests { use crate::Particle; use base64::{engine::general_purpose::STANDARD as base64, Engine}; use fluence_keypair::{KeyFormat, KeyPair}; + use libp2p::PeerId; + use std::str::FromStr; #[test] fn test_signature() { @@ -189,17 +191,18 @@ mod tests { // test particle signature let mut p = Particle { - id: "abc".to_string(), + id: "2883f959-e9e7-4843-8c37-205d393ca372".to_string(), init_peer_id: kp.get_peer_id(), - timestamp: 12345, - ttl: 34, - script: "(abc)".to_string(), + timestamp: 1696934545662, + ttl: 7000, + script: "\n (xor\n (seq\n (call %init_peer_id% (\"load\" \"relay\") [] init_relay)\n (seq\n (call init_relay (\"op\" \"identity\") [\"hello world!\"] result)\n (call %init_peer_id% (\"callback\" \"callback\") [result])\n )\n )\n (seq\n (call init_relay (\"op\" \"identity\") [])\n (call %init_peer_id% (\"callback\" \"error\") [%last_error%])\n )\n )".to_string(), signature: vec![], data: vec![], }; p.sign(&kp).unwrap(); + print!("{}", p.script); assert!(p.verify().is_ok()); - assert_eq!(base64.encode(&p.signature), "yXWURARFMrIwJ0VwDFj5bvaYcODyyRWxLLd+v8JxORnIFDItJW5268JL+S009ENsGuFf6sXhCCdQkeO4tdDrCw=="); + assert_eq!(base64.encode(&p.signature), "gp1iz4EBdrBZIwQWGn3y8DIKtkC37O29oPvz5/+e+qBHY2E75XVc2U/toBEs2+oVuMrJJBuBZ9cOsr+eA+fIBQ=="); } } From bf90b4e5148a2c35fa8e6b908857ac5f5900c48a Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Tue, 10 Oct 2023 13:45:45 +0200 Subject: [PATCH 07/10] update test --- particle-protocol/src/particle.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/particle-protocol/src/particle.rs b/particle-protocol/src/particle.rs index 83a7b7da87..013de6f0bd 100644 --- a/particle-protocol/src/particle.rs +++ b/particle-protocol/src/particle.rs @@ -164,8 +164,6 @@ mod tests { use crate::Particle; use base64::{engine::general_purpose::STANDARD as base64, Engine}; use fluence_keypair::{KeyFormat, KeyPair}; - use libp2p::PeerId; - use std::str::FromStr; #[test] fn test_signature() { @@ -195,14 +193,19 @@ mod tests { init_peer_id: kp.get_peer_id(), timestamp: 1696934545662, ttl: 7000, - script: "\n (xor\n (seq\n (call %init_peer_id% (\"load\" \"relay\") [] init_relay)\n (seq\n (call init_relay (\"op\" \"identity\") [\"hello world!\"] result)\n (call %init_peer_id% (\"callback\" \"callback\") [result])\n )\n )\n (seq\n (call init_relay (\"op\" \"identity\") [])\n (call %init_peer_id% (\"callback\" \"error\") [%last_error%])\n )\n )".to_string(), + script: "abc".to_string(), signature: vec![], data: vec![], }; + let particle_bytes = p.as_bytes(); + assert_eq!( + base64.encode(&particle_bytes), + "Mjg4M2Y5NTktZTllNy00ODQzLThjMzctMjA1ZDM5M2NhMzcy/kguGYsBAABYGwAAYWJj" + ); + p.sign(&kp).unwrap(); - print!("{}", p.script); assert!(p.verify().is_ok()); - assert_eq!(base64.encode(&p.signature), "gp1iz4EBdrBZIwQWGn3y8DIKtkC37O29oPvz5/+e+qBHY2E75XVc2U/toBEs2+oVuMrJJBuBZ9cOsr+eA+fIBQ=="); + assert_eq!(base64.encode(&p.signature), "KceXDnOfqe0dOnAxiDsyWBIvUq6WHoT0ge+VMHXOZsjZvCNH7/10oufdlYfcPomfv28On6E87ZhDcHGBZcb7Bw=="); } } From d613c5aa66f73da3c7f3799b80526d24627e14e6 Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Tue, 10 Oct 2023 14:20:21 +0200 Subject: [PATCH 08/10] pr fixes --- particle-protocol/src/error.rs | 5 ++--- particle-protocol/src/particle.rs | 2 +- script-storage/Cargo.toml | 0 script-storage/src/script_storage.rs | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) delete mode 100644 script-storage/Cargo.toml delete mode 100644 script-storage/src/script_storage.rs diff --git a/particle-protocol/src/error.rs b/particle-protocol/src/error.rs index a21260af86..fec8eda088 100644 --- a/particle-protocol/src/error.rs +++ b/particle-protocol/src/error.rs @@ -31,13 +31,12 @@ pub enum ParticleError { err: SigningError, particle_id: String, }, - #[error("Failed to verify particle {particle} by {peer_id} with signature: {err}")] + #[error("Failed to verify particle {particle_id} by {peer_id} with signature: {err}")] SignatureVerificationFailed { #[source] err: VerificationError, - // particle_id: String, + particle_id: String, peer_id: String, - particle: String, }, #[error("Failed to decode public key from init_peer_id of particle {particle_id}: {err}")] DecodingError { diff --git a/particle-protocol/src/particle.rs b/particle-protocol/src/particle.rs index 013de6f0bd..a3daf6e7bf 100644 --- a/particle-protocol/src/particle.rs +++ b/particle-protocol/src/particle.rs @@ -132,7 +132,7 @@ impl Particle { pk.verify(&self.as_bytes(), &sig) .map_err(|err| SignatureVerificationFailed { err, - particle: serde_json::to_string::(&self).unwrap(), + particle_id: self.id.clone(), peer_id: self.init_peer_id.to_base58(), }) } diff --git a/script-storage/Cargo.toml b/script-storage/Cargo.toml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/script-storage/src/script_storage.rs b/script-storage/src/script_storage.rs deleted file mode 100644 index 8b13789179..0000000000 --- a/script-storage/src/script_storage.rs +++ /dev/null @@ -1 +0,0 @@ - From 9bdfc0e2d26e6f2701185f610da6b8adc5dc3cdb Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Tue, 10 Oct 2023 14:22:28 +0200 Subject: [PATCH 09/10] fix clippy warnings --- crates/system-services/src/distro.rs | 2 +- nox/src/effectors.rs | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/system-services/src/distro.rs b/crates/system-services/src/distro.rs index f36eb14937..8f545b3bdf 100644 --- a/crates/system-services/src/distro.rs +++ b/crates/system-services/src/distro.rs @@ -69,7 +69,7 @@ impl SystemServiceDistros { fn versions_from(packages: &HashMap) -> Versions { let mut versions = Self::default_versions(); for (name, package) in packages { - match ServiceKey::from_string(&name) { + match ServiceKey::from_string(name) { Some(AquaIpfs) => { versions.aqua_ipfs_version = package.version; } diff --git a/nox/src/effectors.rs b/nox/src/effectors.rs index 4388c37f34..61ef491d34 100644 --- a/nox/src/effectors.rs +++ b/nox/src/effectors.rs @@ -51,7 +51,6 @@ impl Effectors { let sent = connectivity.send(contact, particle).await; if sent { // resolved and sent, exit - return; } } } From 0e080d9f60c933b7e742b72bb91f48933febca58 Mon Sep 17 00:00:00 2001 From: Alexey Proshutinskiy Date: Tue, 10 Oct 2023 14:23:39 +0200 Subject: [PATCH 10/10] pr fixes --- aquamarine/src/error.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/aquamarine/src/error.rs b/aquamarine/src/error.rs index abe0920a62..cd807bf4d3 100644 --- a/aquamarine/src/error.rs +++ b/aquamarine/src/error.rs @@ -15,9 +15,10 @@ */ use humantime::FormattedDuration; -use particle_protocol::ParticleError; use thiserror::Error; +use particle_protocol::ParticleError; + #[derive(Debug, Error)] pub enum AquamarineApiError { #[error("AquamarineApiError::ParticleExpired: particle_id = {particle_id}")]