From f15bb75f8d39c7f65abc121883aa86b252d78b00 Mon Sep 17 00:00:00 2001 From: Serhii Mamontov Date: Mon, 16 Oct 2023 17:40:13 +0300 Subject: [PATCH] Add crypto module (#175) feat(crypto): add crypto module Add crypto module that allows to configure SDK to encrypt and decrypt messages. fix(crypto): fix legacy cryptor Improved security of crypto implementation by adding enhanced AES-CBC cryptor. --------- Co-authored-by: Mateusz Dahlke <39696234+Xavrax@users.noreply.github.com> Co-authored-by: Xavrax Co-authored-by: josh-lubliner <103449628+josh-lubliner@users.noreply.github.com> --- .pubnub.yml | 7 +- Cargo.toml | 30 +- LICENSE | 41 ++- README.md | 63 ++-- examples/crypto.rs | 75 +++++ src/core/crypto_provider.rs | 27 ++ src/core/cryptor.rs | 93 ++++-- src/core/error.rs | 13 +- src/core/mod.rs | 6 +- src/dx/publish/mod.rs | 15 +- src/dx/pubnub_client.rs | 17 +- src/dx/subscribe/types.rs | 16 +- src/lib.rs | 66 ++-- src/providers/crypto/crypto_module.rs | 303 ++++++++++++++++++ src/providers/crypto/cryptor_header.rs | 199 ++++++++++++ src/providers/crypto/cryptors/aes_cbc.rs | 205 ++++++++++++ src/providers/crypto/cryptors/legacy.rs | 302 +++++++++++++++++ src/providers/crypto/cryptors/mod.rs | 17 + src/providers/crypto/mod.rs | 98 ++++++ src/providers/mod.rs | 4 +- tests/common/common_steps.rs | 27 ++ tests/contract_test.rs | 7 +- tests/crypto/crypto_steps.rs | 181 +++++++++++ .../crypto/legacy}/crypto_aescbc.rs | 110 +------ tests/crypto/legacy/cryptor.rs | 59 ++++ tests/crypto/legacy/mod.rs | 4 + tests/crypto/mod.rs | 3 + 27 files changed, 1741 insertions(+), 247 deletions(-) create mode 100644 examples/crypto.rs create mode 100644 src/core/crypto_provider.rs create mode 100644 src/providers/crypto/crypto_module.rs create mode 100644 src/providers/crypto/cryptor_header.rs create mode 100644 src/providers/crypto/cryptors/aes_cbc.rs create mode 100644 src/providers/crypto/cryptors/legacy.rs create mode 100644 src/providers/crypto/cryptors/mod.rs create mode 100644 src/providers/crypto/mod.rs create mode 100644 tests/crypto/crypto_steps.rs rename {src/providers => tests/crypto/legacy}/crypto_aescbc.rs (71%) create mode 100644 tests/crypto/legacy/cryptor.rs create mode 100644 tests/crypto/legacy/mod.rs create mode 100644 tests/crypto/mod.rs diff --git a/.pubnub.yml b/.pubnub.yml index 67a2b768..502b2912 100644 --- a/.pubnub.yml +++ b/.pubnub.yml @@ -1,9 +1,14 @@ name: rust -version: 0.3.0 +version: 0.4.0 schema: 1 scm: github.com/pubnub/rust files: [] changelog: + - date: 2023-10-16 + version: 0.4.0 + changes: + - type: feature + text: "Update the crypto module structure and add enhanced AES-CBC cryptor." - date: 2023-08-30 version: 0.3.0 changes: diff --git a/Cargo.toml b/Cargo.toml index f1786602..fa95dd0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "pubnub" -version = "0.3.0" +version = "0.4.0" edition = "2021" license = "MIT" +license-file = "LICENSE" authors = ["PubNub "] description = "PubNub SDK for Rust" repository = "https://github.com/pubnub/rust" -documentation = "https://docs.rs/pubnub/latest/pubnub" +documentation = "https://docs.rs/pubnub/latest/pubnub" homepage = "https://www.pubnub.com" categories = ["api-bindings", "asynchronous", "network-programming", "wasm"] build = "build.rs" @@ -14,10 +15,10 @@ build = "build.rs" [features] # Enables all non-conflicting features -full = ["publish", "subscribe", "presence", "access", "serde", "reqwest", "aescbc", "parse_token", "blocking", "std", "tokio"] +full = ["publish", "subscribe", "presence", "access", "serde", "reqwest", "crypto", "parse_token", "blocking", "std", "tokio"] # Enables all default features -default = ["publish", "subscribe", "serde", "reqwest", "aescbc", "std", "blocking", "tokio"] +default = ["publish", "subscribe", "serde", "reqwest", "std", "blocking", "tokio"] # [PubNub features] @@ -27,8 +28,8 @@ publish = [] ## Enables access manager feature access = [] -## Enables AES-CBC encryption -aescbc = ["dep:aes", "dep:cbc", "getrandom"] +## Enables crypto module +crypto = ["dep:aes", "dep:cbc", "getrandom"] ## Enables token parsing parse_token = ["dep:ciborium"] @@ -48,7 +49,7 @@ tokio = ["dep:tokio"] blocking = ["reqwest?/blocking"] ## Enables std library -std = ["derive_builder/std", "log/std", "uuid/std", "base64/std", "spin/std", "snafu/std", "hmac/std", "sha2/std", "time/std", "bytes?/std", "getrandom/std", "rand/default", "serde?/std", "serde_json?/std", "ciborium?/std", "futures?/std", "futures?/async-await", "dep:async-channel"] +std = ["derive_builder/std", "log/std", "uuid/std", "base64/std", "spin/std", "snafu/std", "hmac/std", "sha2/std", "time/std", "bytes?/std", "getrandom/std", "rand/default", "serde?/std", "serde_json?/std", "ciborium?/std", "futures?/std", "futures?/async-await", "dep:async-channel"] ## Enables very specific implementations for different platforms. ## @@ -60,13 +61,13 @@ std = ["derive_builder/std", "log/std", "uuid/std", "base64/std", "spin/std", "s ## https://docs.rs/portable_atomic ## and ## https://docs.rs/critical-section/latest/critical_section/ -extra_platforms = ["spin/portable_atomic", "dep:portable-atomic"] +extra_platforms = ["spin/portable_atomic", "dep:portable-atomic"] # [Internal features] (not intended for use outside of the library) -contract_test = ["parse_token", "publish", "access"] -full_no_std = ["serde", "reqwest", "aescbc", "parse_token", "blocking", "publish", "access", "subscribe", "tokio", "presence"] -full_no_std_platform_independent = ["serde", "aescbc", "parse_token", "blocking", "publish", "access", "subscribe", "presence"] -pubnub_only = ["aescbc", "parse_token", "blocking", "publish", "access", "subscribe", "presence"] +contract_test = ["parse_token", "publish", "access", "crypto"] +full_no_std = ["serde", "reqwest", "crypto", "parse_token", "blocking", "publish", "access", "subscribe", "tokio", "presence"] +full_no_std_platform_independent = ["serde", "crypto", "parse_token", "blocking", "publish", "access", "subscribe", "presence"] +pubnub_only = ["crypto", "parse_token", "blocking", "publish", "access", "subscribe", "presence"] mock_getrandom = ["getrandom/custom"] # TODO: temporary treated as internal until we officially release it subscribe = ["dep:futures"] @@ -139,6 +140,10 @@ name = "contract_test" harness = false required-features = ["contract_test"] +[[example]] +name = "crypto" +required-features = ["default", "crypto"] + [[example]] name = "publish" required-features = ["default"] @@ -194,4 +199,3 @@ required-features = ["default", "presence"] [[example]] name = "presence_state_blocking" required-features = ["default", "blocking", "presence"] - diff --git a/LICENSE b/LICENSE index 22b11855..5e1ef188 100644 --- a/LICENSE +++ b/LICENSE @@ -1,22 +1,29 @@ -The MIT License (MIT) +PubNub Software Development Kit License Agreement +Copyright Β© 2023 PubNub Inc. All rights reserved. -Copyright (c) 2023 PubNub Inc. +Subject to the terms and conditions of the license, you are hereby granted +a non-exclusive, worldwide, royalty-free license to (a) copy and modify +the software in source code or binary form for use with the software services +and interfaces provided by PubNub, and (b) redistribute unmodified copies +of the software to third parties. The software may not be incorporated in +or used to provide any product or service competitive with the products +and services of PubNub. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +The above copyright notice and this license shall be included +in or with all copies or substantial portions of the software. -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +This license does not grant you permission to use the trade names, trademarks, +service marks, or product names of PubNub, except as required for reasonable +and customary use in describing the origin of the software and reproducing +the content of this license. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +THE SOFTWARE IS PROVIDED β€œAS IS”, WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL PUBNUB OR THE AUTHORS OR COPYRIGHT HOLDERS OF THE SOFTWARE BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF +CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +https://www.pubnub.com/ +https://www.pubnub.com/terms diff --git a/README.md b/README.md index 370bec9c..ea8e056b 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ This is the official PubNub Rust SDK repository. [PubNub](https://www.pubnub.com/) takes care of the infrastructure and APIs needed for the realtime -communication layer of your application. Work on your app's logic and let PubNub handle sending and receiving -data across the world in less than 100ms. +communication layer of your application. Work on your app's logic and let +PubNub handle sending and receiving data across the world in less than +100ms. ## Getting started @@ -35,11 +36,11 @@ Add `pubnub` to your Rust project in the `Cargo.toml` file: ```toml # default features [dependencies] -pubnub = "0.3.0" +pubnub = "0.4.0" # all features [dependencies] -pubnub = { version = "0.3.0", features = ["full"] } +pubnub = { version = "0.4.0", features = ["full"] } ``` ### Example @@ -125,24 +126,25 @@ You can find more examples in our [examples](https://github.com/pubnub/rust/tree ## Features -The `pubnub` crate is split into multiple features. You can enable or disable them in the `Cargo.toml` file, like so: +The `pubnub` crate is split into multiple features. You can enable or +disable them in the `Cargo.toml` file, like so: ```toml # only blocking and access + default features [dependencies] -pubnub = { version = "0.3.0", features = ["blocking", "access"] } +pubnub = { version = "0.4.0", features = ["blocking", "access"] } # only parse_token + default features [dependencies] -pubnub = { version = "0.3.0", features = ["parse_token"] } +pubnub = { version = "0.4.0", features = ["parse_token"] } ``` ### Available features | Feature name | Description | Available PubNub APIs | | :------------ | :---------- | :------------- | -| `full` | Enables all non-conflicting features | Configuration, Publish, Subscribe, Access Manager, Parse Token, Presence | -| `default` | Enables default features: `publish`, `subscribe`, `serde`, `reqwest`, `aescbc`, `std` | Configuration, Publish, Subscribe | +| `full` | Enables all non-conflicting features | Configuration, Publish, Subscribe, Access Manager, Parse Token, Presence, Crypto Module | +| `default` | Enables default features: `publish`, `subscribe`, `serde`, `reqwest`, `std` | Configuration, Publish, Subscribe | | `publish` | Enables Publish API | Configuration, Publish | | `access` | Enables Access Manager API | Configuration, Access Manager | | `parse_token` | Enables parsing Access Manager tokens | Configuration, Parse Token | @@ -152,7 +154,7 @@ pubnub = { version = "0.3.0", features = ["parse_token"] } | `serde` | Uses [serde](https://github.com/serde-rs/serde) for serialization | n/a | | `reqwest` | Uses [reqwest](https://github.com/seanmonstar/reqwest) as a transport layer | n/a | | `blocking` | Enables blocking executions of APIs | n/a | -| `aescbc` | Enables AES-CBC encryption | n/a | +| `crypto` | Enables crypto module for data encryption and decryption | n/a | | `std` | Enables `std` library | n/a | ## Documentation @@ -162,16 +164,18 @@ pubnub = { version = "0.3.0", features = ["parse_token"] } ## Wasm support -The `pubnub` crate is compatible with WebAssembly. You can use it in your Wasm project. +The `pubnub` crate is compatible with WebAssembly. You can use it in your +Wasm project. ## `no_std` support -The `pubnub` crate is `no_std` compatible. To use it in a `no_std` environment, you have to disable the default -features and enable the ones you need, for example: +The `pubnub` crate is `no_std` compatible. To use it in a `no_std` +environment, you have to disable the default features and enable the ones +you need, for example: ```toml [dependencies] -pubnub = { version = "0.3.0", default-features = false, features = ["serde", "publish", +pubnub = { version = "0.4.0", default-features = false, features = ["serde", "publish", "blocking"] } ``` @@ -179,35 +183,42 @@ pubnub = { version = "0.3.0", default-features = false, features = ["serde", "pu The `no_std` support is limited by the implementation details of the SDK. -The SDK uses the `alloc` crate to allocate memory for some operations, which means that -certain targets aren't supported. Additionally, as we provide a synchronous API, we use -some parts of the `alloc::sync` module, which is also not supported in certain `no_std` environments. +The SDK uses the `alloc` crate to allocate memory for some operations, which +means that certain targets aren't supported. Additionally, as we provide a +synchronous API, we use some parts of the `alloc::sync` module, which is +also not supported in certain `no_std` environments. Some SDK features aren't supported in a `no_std` environment: * partially `access` module (because of lack of timestamp support) -* partially `reqwest` transport (because of the reqwest implementation details) -* partially `subscribe` module (because of the spawning tasks and time dependence) -* partially `presence` module (because of the spawning tasks and time dependence) +* partially `reqwest` transport (because of the reqwest implementation + details) +* partially `subscribe` module (because of the spawning tasks and time + dependence) +* partially `presence` module (because of the spawning tasks and time + dependence) * `std` feature (because of the `std` library) -We depend on a random number generator to generate data for debugging purposes. -If you want to use the SDK in a `no_std` environment, you'll have to provide -your own random number generator implementation for certain targets. +We depend on a random number generator to generate data for debugging +purposes. If you want to use the SDK in a `no_std` environment, you'll have +to provide your own random number generator implementation for certain +targets. See more: * [`getrandom` crate](https://docs.rs/getrandom/latest/getrandom/) * [no_std examples](https://github.com/pubnub/rust/tree/master/examples/no_std/) -If you're having problems compiling this crate for more exotic targets, you can try to use the -`extra_platforms` feature. Be aware that this feature is **not supported** and we do not recommend using it. +If you're having problems compiling this crate for more exotic targets, you +can try to use the `extra_platforms` feature. Be aware that this feature is +**not supported** and we do not recommend using it. For more information about this feature. refer to [Cargo.toml](https://github.com/pubnub/rust/blob/master/Cargo.toml) in the `[features]` section. ## Support -If you **need help** or have a **general question**, contact support@pubnub.com. +If you **need help** or have a **general question**, contact +support@pubnub.com. ## License diff --git a/examples/crypto.rs b/examples/crypto.rs new file mode 100644 index 00000000..9584b6e8 --- /dev/null +++ b/examples/crypto.rs @@ -0,0 +1,75 @@ +use pubnub::core::CryptoProvider; +use pubnub::providers::crypto::CryptoModule; +use pubnub::{Keyset, PubNubClientBuilder}; +use std::env; + +/// This example demonstrates how data can be manually encrypted or +/// automatically as part of PubNubClient instance when publish / subscribe is +/// used. +/// +/// The following example consists of two parts: a manual _encryption_ and +/// _decryption_ demonstration, and an automated _encryption_ and _decryption_ +/// demonstration, as part of a configured `PubNubClientInstance`. + +#[tokio::main] +async fn main() -> Result<(), Box> { + let source_data: Vec = "Hello world!".into(); + let use_random_iv = true; + let cipher = "enigma"; + + // ----------------------------------------- + // Manual encryption and decryption example. + // + + // Crypto module with legacy AES-CBC cryptor (with enhanced AES-CBC decrypt + // support). + let legacy_crypto_module = CryptoModule::new_legacy_module(cipher, use_random_iv)?; + let legacy_encrypt_result = legacy_crypto_module.encrypt(source_data.clone()); + + println!("encrypt with legacy AES-CBC result: {legacy_encrypt_result:?}"); + + // Crypto module with enhanced AES-CBC cryptor (with legacy AES-CBC decrypt + // support). + let crypto_module = CryptoModule::new_aes_cbc_module(cipher, use_random_iv)?; + let encrypt_result = crypto_module.encrypt(source_data.clone()); + + println!("encrypt with enhanced AES-CBC result: {encrypt_result:?}"); + + // Decrypt data created with legacy AES-CBC crypto module. + let legacy_decrypt_result = crypto_module.decrypt(legacy_encrypt_result.ok().unwrap())?; + assert_eq!(legacy_decrypt_result, source_data); + + // Decrypt data created with enhanced AES-CBC crypto module. + let decrypt_result = legacy_crypto_module.decrypt(encrypt_result.ok().unwrap())?; + assert_eq!(decrypt_result, source_data); + + // -------------------------------------------- + // Automated encryption and decryption example. + // + + // Setup client with crypto module + let publish_key = env::var("SDK_PUB_KEY")?; + let subscribe_key = env::var("SDK_SUB_KEY")?; + + let client = PubNubClientBuilder::with_reqwest_transport() + .with_keyset(Keyset { + subscribe_key, + publish_key: Some(publish_key), + secret_key: None, + }) + .with_user_id("user_id") + .with_cryptor(crypto_module) + .build()?; + + // publish encrypted string + let result = client + .publish_message("hello world!") + .channel("my_channel") + .r#type("text-message") + .execute() + .await?; + + println!("publish result: {:?}", result); + + Ok(()) +} diff --git a/src/core/crypto_provider.rs b/src/core/crypto_provider.rs new file mode 100644 index 00000000..303df234 --- /dev/null +++ b/src/core/crypto_provider.rs @@ -0,0 +1,27 @@ +//! # Crypto provider module +//! +//! This module contains the [`CryptoProvider`] trait, which is used to +//! implement a module that can be used to configure [`PubNubClientInstance`] or +//! for manual data encryption and decryption. + +use crate::{ + core::PubNubError, + lib::{alloc::vec::Vec, core::fmt::Debug}, +}; + +/// Crypto provider trait. +pub trait CryptoProvider: Debug + Send + Sync { + /// Encrypt provided data. + /// + /// # Errors + /// Should return an [`PubNubError::Encryption`] if provided data can't be + /// _encrypted_ or underlying cryptor misconfigured. + fn encrypt(&self, data: Vec) -> Result, PubNubError>; + + /// Decrypt provided data. + /// + /// # Errors + /// Should return an [`PubNubError::Decryption`] if provided data can't be + /// _decrypted_ or underlying cryptor misconfigured. + fn decrypt(&self, data: Vec) -> Result, PubNubError>; +} diff --git a/src/core/cryptor.rs b/src/core/cryptor.rs index b42ef131..db099396 100644 --- a/src/core/cryptor.rs +++ b/src/core/cryptor.rs @@ -1,59 +1,98 @@ -//! Cryptor module +//! # Cryptor module //! -//! This module contains the [`Cryptor`] trait which is used to implement -//! encryption and decryption of published data. +//! This module contains the [`Cryptor`] trait, which is used to implement +//! crypto algorithms that should be used with [`CryptorProvider`] +//! implementation for data _encryption_ and _decryption_. -use crate::core::error::PubNubError; -use crate::lib::{alloc::vec::Vec, core::fmt::Debug}; +use crate::{ + core::PubNubError, + lib::{alloc::vec::Vec, core::fmt::Debug}, +}; -/// This trait is used to encrypt and decrypt messages sent to the -/// [`PubNub API`]. +/// Encrypted data representation object. /// -/// It is used by the [`dx`] modules to encrypt messages sent to PubNub and -/// returned by the [`PubNub API`]. +/// Objects contain both encrypted data and additional data created by cryptor +/// that will be required to decrypt the data. +#[derive(Debug)] +pub struct EncryptedData { + /// Cryptor-defined information. + /// + /// Cryptor may provide here any information which will be useful when data + /// should be decrypted. + /// + /// For example `metadata` may contain: + /// * initialization vector + /// * cipher key identifier + /// * encrypted `data` length. + pub metadata: Option>, + + /// Encrypted data. + pub data: Vec, +} + +/// Cryptor trait. +/// +/// Types that implement this trait can be used to configure [`CryptoProvider`] +/// implementations for standalone usage or as part of [`PubNubClientInstance`] +/// for automated data _encryption_ and _decryption_. /// /// To implement this trait, you must provide `encrypt` and `decrypt` methods -/// that takes a `&[u8]` and returns a `Result, PubNubError>`. +/// that takes a `Vec` and returns a `Result`. /// /// You can implement this trait for your own types, or use one of the provided -/// features to use a crypto library. -/// When you use this trait to make your own crypto, make sure that other SDKs -/// use the same encryption and decryption algorithms. +/// features to use a `crypto` library. +/// +/// You can implement this trait for your cryptor types, or use one of the +/// implementations provided by `crypto` feature. +/// When you implement your cryptor for custom encryption and use multiple +/// platforms, make sure that the same logic is implemented for other SDKs. /// /// # Examples /// ``` -/// use pubnub::core::{Cryptor, error::PubNubError}; +/// use pubnub::core::{Cryptor, EncryptedData, error::PubNubError}; /// /// #[derive(Debug)] /// struct MyCryptor; /// /// impl Cryptor for MyCryptor { -/// fn encrypt(&self, source: Vec) -> Result, PubNubError> { +/// fn identifier(&self) -> [u8; 4] { +/// *b"MCID" +/// } +/// +/// fn encrypt(&self, source: Vec) -> Result { /// // Encrypt provided data here -/// Ok(vec![]) +/// Ok(EncryptedData { +/// metadata: None, +/// data: vec![] +/// }) /// } /// -/// fn decrypt(&self, source: Vec) -> Result, PubNubError> { +/// fn decrypt(&self, source: EncryptedData) -> Result, PubNubError> { /// // Decrypt provided data here /// Ok(vec![]) /// } /// } /// ``` -/// -/// [`dx`]: ../dx/index.html -/// [`PubNub API`]: https://www.pubnub.com/docs pub trait Cryptor: Debug + Send + Sync { - /// Decrypt provided data. + /// Unique cryptor identifier. + /// + /// Identifier will be encoded into cryptor data header and passed along + /// with encrypted data. + /// + /// The identifier **must** be 4 bytes long. + fn identifier(&self) -> [u8; 4]; + + /// Encrypt provided data. /// /// # Errors - /// Should return an [`PubNubError::Encryption`] if provided data can't - /// be encrypted or underlying cryptor misconfigured. - fn encrypt(&self, source: Vec) -> Result, PubNubError>; + /// Should return an [`PubNubError::Encryption`] if provided data can't be + /// _encrypted_ or underlying cryptor misconfigured. + fn encrypt(&self, data: Vec) -> Result; /// Decrypt provided data. /// /// # Errors - /// Should return an [`PubNubError::Decryption`] if provided data can't - /// be decrypted or underlying cryptor misconfigured. - fn decrypt(&self, source: Vec) -> Result, PubNubError>; + /// Should return an [`PubNubError::Decryption`] if provided data can't be + /// _decrypted_ or underlying cryptor misconfigured. + fn decrypt(&self, data: EncryptedData) -> Result, PubNubError>; } diff --git a/src/core/error.rs b/src/core/error.rs index 8d027398..e5b439b3 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -87,26 +87,33 @@ pub enum PubNubError { }, /// this error is returned when the initialization of the cryptor fails - #[snafu(display("Cryptor initialization error: {details}"))] + #[snafu(display("Crypto initialization error: {details}"))] CryptoInitialization { ///docs details: String, }, - /// this error is returned when the cryptor is unable to decrypt data + /// this error is returned when the cryptor is unable to encrypt data #[snafu(display("Data encryption error: {details}"))] Encryption { ///docs details: String, }, - /// this error is returned when the crypror is unable to decrypt data + /// this error is returned when the cryptor is unable to decrypt data #[snafu(display("Data decryption error: {details}"))] Decryption { ///docs details: String, }, + /// this error returned when suitable cryptor not found for data decryption. + #[snafu(display("Unknown cryptor error: {details}"))] + UnknownCryptor { + /// docs + details: String, + }, + /// this error is returned when the event engine effect is canceled #[snafu(display("Event engine effect has been canceled"))] EffectCanceled, diff --git a/src/core/mod.rs b/src/core/mod.rs index da33060d..dedec70f 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -59,7 +59,11 @@ pub use serialize::Serialize; pub mod serialize; #[doc(inline)] -pub use cryptor::Cryptor; +pub use crypto_provider::CryptoProvider; +pub mod crypto_provider; + +#[doc(inline)] +pub use cryptor::{Cryptor, EncryptedData}; pub mod cryptor; #[cfg(all(feature = "std", feature = "subscribe"))] diff --git a/src/dx/publish/mod.rs b/src/dx/publish/mod.rs index 13ce2141..d73ef31a 100644 --- a/src/dx/publish/mod.rs +++ b/src/dx/publish/mod.rs @@ -28,7 +28,7 @@ use crate::{ encoding::{url_encode, url_encode_extended, UrlEncodeExtension}, headers::{APPLICATION_JSON, CONTENT_TYPE}, }, - Cryptor, Deserializer, PubNubError, Serialize, Transport, TransportMethod, + CryptoProvider, Deserializer, PubNubError, Serialize, Transport, TransportMethod, TransportRequest, }, dx::pubnub_client::{PubNubClientInstance, PubNubConfig}, @@ -266,7 +266,7 @@ where fn create_transport_request( self, config: &PubNubConfig, - cryptor: &Option>, + cryptor: &Option>, ) -> Result { let query_params = self.prepare_publish_query_params(); @@ -401,7 +401,10 @@ mod should { use crate::{ core::TransportResponse, dx::pubnub_client::{PubNubClientInstance, PubNubClientRef, PubNubConfig}, - lib::alloc::{sync::Arc, vec}, + lib::{ + alloc::{sync::Arc, vec}, + collections::HashMap, + }, transport::middleware::PubNubMiddleware, Keyset, PubNubClientBuilder, }; @@ -559,7 +562,7 @@ mod should { fn test_send_map_when_get() { let client = client(); let channel = String::from("ch"); - let message = HashMap::from([("a", "b")]); + let message: HashMap<&str, &str> = HashMap::from([("a", "b")]); let result = client .publish_message(message) @@ -605,7 +608,7 @@ mod should { fn test_path_segments_get() { let client = client(); let channel = String::from("channel_name"); - let message = HashMap::from([("number", 7)]); + let message: HashMap<&str, u8> = HashMap::from([("number", 7)]); let result = client .publish_message(message) @@ -630,7 +633,7 @@ mod should { fn test_path_segments_post() { let client = client(); let channel = String::from("channel_name"); - let message = HashMap::from([("number", 7)]); + let message: HashMap<&str, u8> = HashMap::from([("number", 7)]); let result = client .publish_message(message) diff --git a/src/dx/pubnub_client.rs b/src/dx/pubnub_client.rs index 96eeed9a..86bab779 100644 --- a/src/dx/pubnub_client.rs +++ b/src/dx/pubnub_client.rs @@ -27,7 +27,7 @@ use crate::transport::TransportReqwest; use crate::core::RequestRetryPolicy; use crate::{ - core::{Cryptor, PubNubError}, + core::{CryptoProvider, PubNubError}, lib::{ alloc::{ string::{String, ToString}, @@ -277,7 +277,7 @@ pub struct PubNubClientRef { field(vis = "pub(crate)"), default = "None" )] - pub(crate) cryptor: Option>, + pub(crate) cryptor: Option>, /// Instance ID #[builder( @@ -450,15 +450,15 @@ impl PubNubClientConfigBuilder { /// Data encryption / decryption /// - /// Cryptor used by client when publish messages / signals and receive them - /// as real-time updates from subscription module. + /// Crypto module used by client when publish messages / signals and receive + /// them as real-time updates from subscription module. /// /// It returns [`PubNubClientConfigBuilder`] that you can use to set the /// configuration for the client. This is a part of the /// [`PubNubClientConfigBuilder`]. pub fn with_cryptor(mut self, cryptor: C) -> Self where - C: Cryptor + Send + Sync + 'static, + C: CryptoProvider + Send + Sync + 'static, { self.cryptor = Some(Some(Arc::new(cryptor))); @@ -1157,6 +1157,7 @@ impl PubNubClientDeserializerBuilder { transport: self.transport, deserializer: DeserializerSerde, keyset, + #[cfg(all(any(feature = "subscribe", feature = "presence"), feature = "std"))] runtime: self.runtime, } @@ -1196,6 +1197,7 @@ where transport: T, deserializer: D, keyset: Keyset, + #[cfg(all(any(feature = "subscribe", feature = "presence"), feature = "std"))] runtime: RuntimeSupport, } @@ -1225,15 +1227,20 @@ where secret_key, user_id: Arc::new(user_id.into()), auth_key: None, + #[cfg(feature = "std")] retry_policy: Default::default(), + #[cfg(any(feature = "subscribe", feature = "presence"))] heartbeat_value: 300, + #[cfg(any(feature = "subscribe", feature = "presence"))] heartbeat_interval: None, }), + #[cfg(all(any(feature = "subscribe", feature = "presence"), feature = "std"))] runtime: Some(self.runtime), + deserializer: Some(Arc::new(self.deserializer)), ..Default::default() } diff --git a/src/dx/subscribe/types.rs b/src/dx/subscribe/types.rs index 09876ad3..62153e46 100644 --- a/src/dx/subscribe/types.rs +++ b/src/dx/subscribe/types.rs @@ -1,7 +1,7 @@ //! Subscription types module. use crate::{ - core::{Cryptor, PubNubError, ScalarValue}, + core::{CryptoProvider, PubNubError, ScalarValue}, dx::subscribe::result::{Envelope, EnvelopePayload, ObjectDataBody, Update}, lib::{ alloc::{ @@ -56,7 +56,7 @@ pub enum SubscribeMessageType { /// Object related event. /// /// This type is set to the group of events which is related to the - /// `user Id` / `channel` objects and their relationship changes. + /// `user ID` / `channel` objects and their relationship changes. Object = 2, /// Message action related event. @@ -395,8 +395,8 @@ pub struct Message { /// Decryption error details. /// - /// Error is set when [`PubNubClient`] configured with cryptor and it wasn't - /// able to decrypt [`data`] in this message. + /// Error is set when [`PubNubClient`] configured with cryptor, and it + /// wasn't able to decrypt [`data`] in this message. pub decryption_error: Option, } @@ -566,7 +566,7 @@ impl Object { /// Name of subscription. /// /// Name of channel or channel group on which client subscribed and through - /// which which object update has been triggered. + /// which object update has been triggered. pub(crate) fn subscription(&self) -> String { match self { Object::Channel { subscription, .. } @@ -580,7 +580,7 @@ impl Update { /// Decrypt real-time update. pub(in crate::dx::subscribe) fn decrypt( self, - cryptor: &Arc, + cryptor: &Arc, ) -> Self { if !matches!(self, Self::Message(_) | Self::Signal(_)) { return self; @@ -596,7 +596,7 @@ impl Update { impl Message { /// Decrypt message payload if possible. - fn decrypt(mut self, cryptor: &Arc) -> Self { + fn decrypt(mut self, cryptor: &Arc) -> Self { let lossy_string = String::from_utf8_lossy(self.data.as_slice()).to_string(); let trimmed = lossy_string.trim_matches('"'); let decryption_result = general_purpose::STANDARD @@ -912,7 +912,7 @@ fn resolve_subscription_value(subscription: Option, channel: &str) -> St subscription.unwrap_or(channel.to_owned()) } -// TODO: add tests for complicated froms. +// TODO: add tests for complicated forms. #[cfg(test)] mod should { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 4bc31308..8fc95901 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,8 +20,9 @@ //! This is the official PubNub Rust SDK repository. //! //! [PubNub](https://www.pubnub.com/) takes care of the infrastructure and APIs needed for the realtime -//! communication layer of your application. Work on your app's logic and let PubNub handle sending and receiving -//! data across the world in less than 100ms. +//! communication layer of your application. Work on your app's logic and let +//! PubNub handle sending and receiving data across the world in less than +//! 100ms. //! //! ## Getting started //! @@ -38,11 +39,11 @@ //! ```toml //! # default features //! [dependencies] -//! pubnub = "0.3.0" +//! pubnub = "0.4.0" //! //! # all features //! [dependencies] -//! pubnub = { version = "0.3.0", features = ["full"] } +//! pubnub = { version = "0.4.0", features = ["full"] } //! ``` //! //! ### Example @@ -128,24 +129,25 @@ //! //! ## Features //! -//! The `pubnub` crate is split into multiple features. You can enable or disable them in the `Cargo.toml` file, like so: +//! The `pubnub` crate is split into multiple features. You can enable or +//! disable them in the `Cargo.toml` file, like so: //! //! ```toml //! # only blocking and access + default features //! [dependencies] -//! pubnub = { version = "0.3.0", features = ["blocking", "access"] } +//! pubnub = { version = "0.4.0", features = ["blocking", "access"] } //! //! # only parse_token + default features //! [dependencies] -//! pubnub = { version = "0.3.0", features = ["parse_token"] } +//! pubnub = { version = "0.4.0", features = ["parse_token"] } //! ``` //! //! ### Available features //! //! | Feature name | Description | Available PubNub APIs | //! | :------------ | :---------- | :------------- | -//! | `full` | Enables all non-conflicting features | Configuration, Publish, Subscribe, Access Manager, Parse Token, Presence | -//! | `default` | Enables default features: `publish`, `subscribe`, `serde`, `reqwest`, `aescbc`, `std` | Configuration, Publish, Subscribe | +//! | `full` | Enables all non-conflicting features | Configuration, Publish, Subscribe, Access Manager, Parse Token, Presence, Crypto Module | +//! | `default` | Enables default features: `publish`, `subscribe`, `serde`, `reqwest`, `std` | Configuration, Publish, Subscribe | //! | `publish` | Enables Publish API | Configuration, Publish | //! | `access` | Enables Access Manager API | Configuration, Access Manager | //! | `parse_token` | Enables parsing Access Manager tokens | Configuration, Parse Token | @@ -155,7 +157,7 @@ //! | `serde` | Uses [serde](https://github.com/serde-rs/serde) for serialization | n/a | //! | `reqwest` | Uses [reqwest](https://github.com/seanmonstar/reqwest) as a transport layer | n/a | //! | `blocking` | Enables blocking executions of APIs | n/a | -//! | `aescbc` | Enables AES-CBC encryption | n/a | +//! | `crypto` | Enables crypto module for data encryption and decryption | n/a | //! | `std` | Enables `std` library | n/a | //! //! ## Documentation @@ -165,16 +167,18 @@ //! //! ## Wasm support //! -//! The `pubnub` crate is compatible with WebAssembly. You can use it in your Wasm project. +//! The `pubnub` crate is compatible with WebAssembly. You can use it in your +//! Wasm project. //! //! ## `no_std` support //! -//! The `pubnub` crate is `no_std` compatible. To use it in a `no_std` environment, you have to disable the default -//! features and enable the ones you need, for example: +//! The `pubnub` crate is `no_std` compatible. To use it in a `no_std` +//! environment, you have to disable the default features and enable the ones +//! you need, for example: //! //! ```toml //! [dependencies] -//! pubnub = { version = "0.3.0", default-features = false, features = ["serde", "publish", +//! pubnub = { version = "0.4.0", default-features = false, features = ["serde", "publish", //! "blocking"] } //! ``` //! @@ -182,35 +186,42 @@ //! //! The `no_std` support is limited by the implementation details of the SDK. //! -//! The SDK uses the `alloc` crate to allocate memory for some operations, which means that -//! certain targets aren't supported. Additionally, as we provide a synchronous API, we use -//! some parts of the `alloc::sync` module, which is also not supported in certain `no_std` environments. +//! The SDK uses the `alloc` crate to allocate memory for some operations, which +//! means that certain targets aren't supported. Additionally, as we provide a +//! synchronous API, we use some parts of the `alloc::sync` module, which is +//! also not supported in certain `no_std` environments. //! //! Some SDK features aren't supported in a `no_std` environment: //! //! * partially `access` module (because of lack of timestamp support) -//! * partially `reqwest` transport (because of the reqwest implementation details) -//! * partially `subscribe` module (because of the spawning tasks and time dependence) -//! * partially `presence` module (because of the spawning tasks and time dependence) +//! * partially `reqwest` transport (because of the reqwest implementation +//! details) +//! * partially `subscribe` module (because of the spawning tasks and time +//! dependence) +//! * partially `presence` module (because of the spawning tasks and time +//! dependence) //! * `std` feature (because of the `std` library) //! -//! We depend on a random number generator to generate data for debugging purposes. -//! If you want to use the SDK in a `no_std` environment, you'll have to provide -//! your own random number generator implementation for certain targets. +//! We depend on a random number generator to generate data for debugging +//! purposes. If you want to use the SDK in a `no_std` environment, you'll have +//! to provide your own random number generator implementation for certain +//! targets. //! //! See more: //! //! * [`getrandom` crate](https://docs.rs/getrandom/latest/getrandom/) //! * [no_std examples](https://github.com/pubnub/rust/tree/master/examples/no_std/) //! -//! If you're having problems compiling this crate for more exotic targets, you can try to use the -//! `extra_platforms` feature. Be aware that this feature is **not supported** and we do not recommend using it. +//! If you're having problems compiling this crate for more exotic targets, you +//! can try to use the `extra_platforms` feature. Be aware that this feature is +//! **not supported** and we do not recommend using it. //! //! For more information about this feature. refer to [Cargo.toml](https://github.com/pubnub/rust/blob/master/Cargo.toml) in the `[features]` section. //! //! ## Support //! -//! If you **need help** or have a **general question**, contact support@pubnub.com. +//! If you **need help** or have a **general question**, contact +//! support@pubnub.com. //! //! ## License //! @@ -276,7 +287,8 @@ mod lib { /// Depending of the `std` feature, this module will re-export /// either `std::collections::HashMap` or `hashbrown::HashMap`. /// This is needed because there is no `no_std` HashMap available. - /// We decided to use `hashbrown` because it is fast and has the same API as `std` HashMap. + /// We decided to use `hashbrown` because it is fast and has the + /// same API as `std` HashMap. #[cfg(not(feature = "std"))] pub(crate) use hashbrown::HashMap; diff --git a/src/providers/crypto/crypto_module.rs b/src/providers/crypto/crypto_module.rs new file mode 100644 index 00000000..5341c1e1 --- /dev/null +++ b/src/providers/crypto/crypto_module.rs @@ -0,0 +1,303 @@ +//! # Crypto module + +use crate::{ + core::{CryptoProvider, Cryptor, EncryptedData, PubNubError}, + lib::alloc::{boxed::Box, format, string::String, vec, vec::Vec}, + providers::crypto::CryptorHeader, +}; + +/// PubNub client crypto module. +/// +/// Module used by [`PubNubClientInstance`] for automated data encryption and +/// decryption. This module can be used separately for manual data encryption +/// and decryption. +/// +/// # Examples +/// Create crypto module using convenience functions: +/// +/// * with currently used implementation for encryption: +/// ```rust +/// # use pubnub::{ +/// # core::PubNubError, +/// # providers::crypto::CryptoModule +/// # }; +/// # +/// # fn main() -> Result<(), PubNubError> { +/// let crypto_module = CryptoModule::new_legacy_module("enigma", true)?; +/// # Ok(()) +/// # } +/// ``` +/// +/// * with newer cryptor version: +/// ```rust +/// # use pubnub::{ +/// # core::PubNubError, +/// # providers::crypto::CryptoModule +/// # }; +/// # +/// # fn main() -> Result<(), PubNubError> { +/// let crypto_module = CryptoModule::new_aes_cbc_module("enigma", true)?; +/// # Ok(()) +/// # } +/// ``` +/// +/// Create cryptor module with custom set of cryptors: +/// ```rust +/// # use pubnub::{ +/// # core::PubNubError, +/// # providers::crypto::{AesCbcCryptor, CryptoModule, LegacyCryptor} +/// # }; +/// # +/// # fn main() -> Result<(), PubNubError> { +/// let crypto_module = CryptoModule::new( +/// Box::new(LegacyCryptor::new("enigma".clone(), true)?), +/// Some(vec![Box::new(AesCbcCryptor::new("enigma")?)]), +/// ); +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug)] +pub struct CryptoModule { + /// Default cryptor. + /// + /// Default cryptor used for data _encryption_ and _decryption_. + default: Box, + + /// List of known cryptors. + /// + /// List of cryptors which is used to _decrypt_ data encrypted by previously + /// used cryptors. + cryptors: Option>>, +} + +impl CryptoModule { + /// Create crypto module. + /// + /// `default` used to _encrypt_ and _decrypt_ corresponding data while rest + /// of `cryptors` will be used to _decrypt_ data encrypted by previously + /// used cryptors. + pub fn new(default: Box, cryptors: Option>>) -> Self { + Self { default, cryptors } + } + + /// Find cryptor suitable to handle data. + fn cryptor_with_identifier(&self, header: &CryptorHeader) -> Option<&dyn Cryptor> { + // Check whether there is no header - it mean that legacy cryptor should + // be used. + let identifier = header.identifier().unwrap_or([0x00u8; 4]); + + // Check whether default cryptor can handle data or not. + if self.default.identifier().eq(&identifier) { + return Some(self.default.as_ref()); + } + + // Return first matching cryptor. + self.cryptors.as_ref().and_then(|cryptors| { + cryptors + .iter() + .position(|cryptor| cryptor.identifier().eq(&identifier)) + .map(|position| cryptors[position].as_ref()) + }) + } +} + +impl CryptoProvider for CryptoModule { + /// Encrypt provided data. + /// + /// # Examples + /// ```rust + /// # use pubnub::{ + /// # core::{PubNubError, CryptoProvider}, + /// # providers::crypto::CryptoModule + /// # }; + /// # + /// # fn main() -> Result<(), PubNubError> { + /// let crypto_module = CryptoModule::new_aes_cbc_module("enigma", true)?; + /// let result = crypto_module.encrypt(Vec::from("hello world!"))?; + /// # Ok(()) + /// # } + /// ``` + fn encrypt(&self, data: Vec) -> Result, PubNubError> { + // Encrypting provided data. + let encrypted = self.default.encrypt(data)?; + + // Compute cryptor header. + let header = CryptorHeader::new(self.default.identifier(), &encrypted.metadata); + + // Concatenate encrypted data with header into single payload. + let mut payload = vec![0; header.len()]; + let mut pos = header.len(); + let header_data: Vec = header.into(); + payload.splice(0..pos, header_data); + if let Some(metadata) = encrypted.metadata { + pos -= metadata.len(); + payload.splice(pos..(pos + metadata.len()), metadata.clone()); + } + payload.extend(encrypted.data); + + Ok(payload) + } + + /// Decrypt provided data. + /// + /// # Examples + /// ```rust + /// # use base64::{engine::general_purpose, Engine as _}; + /// # use pubnub::{ + /// # core::{PubNubError, CryptoProvider}, + /// # providers::crypto::CryptoModule + /// # }; + /// # + /// # fn main() -> Result<(), PubNubError> { + /// let encrypted_data = // + /// # general_purpose::STANDARD + /// # .decode( + /// # "UE5FRAFBQ1JIELzZwCmyT4vQLcjIAf8hSX2/mRRRby+egFPTqmwSKIFcjI1V/ig/y3M1iTlwknrTSw==", + /// # ) + /// # .expect("Valid base64 encoded string required."); + /// let crypto_module = CryptoModule::new_aes_cbc_module("enigma", true)?; + /// let result = crypto_module.decrypt(encrypted_data)?; + /// # + /// # assert_eq!(result, "\"Hello there πŸ™ƒ\"".as_bytes()); + /// # Ok(()) + /// # } + /// ``` + fn decrypt(&self, data: Vec) -> Result, PubNubError> { + if data.is_empty() { + return Err(PubNubError::Decryption { + details: "Decrypted data is empty".into(), + }); + } + + // Try read header content from received data. + let header = CryptorHeader::try_from(&data)?; + + // Checking whether any cryptor for specified identifier has been found. + let Some(cryptor) = self.cryptor_with_identifier(&header) else { + let identifier = header.identifier().unwrap_or(*b"UNKN"); + // Looks like payload with unknown cryptor identifier has been received. + Err(PubNubError::UnknownCryptor { + details: format!( + "Decrypting data created by unknown cryptor. Please make sure to register {} \ + or update SDK", + String::from_utf8(identifier.to_vec()).unwrap_or("non-utf8 identifier".into()) + ), + })? + }; + + let metadata = match header.data_size() { + Some(size) => { + let offset = header.len() - size; + Some(data[offset..(offset + size)].to_vec()) + } + None => None, + }; + + cryptor.decrypt(EncryptedData { + metadata, + data: data[header.len()..].to_vec(), + }) + } +} + +#[cfg(test)] +mod it_should { + use super::*; + + const IDENTIFIER: [u8; 4] = *b"ABCD"; + const METADATA: [u8; 17] = *b"this-is-meta-data"; + const ENCRYPTED_DATA: [u8; 22] = *b"this-is-encrypted-data"; + const DECRYPTED_DATA: [u8; 22] = *b"this-is-decrypted-data"; + + #[derive(Debug)] + struct MyCryptor; + + impl Cryptor for MyCryptor { + fn identifier(&self) -> [u8; 4] { + IDENTIFIER + } + + fn encrypt(&self, data: Vec) -> Result { + assert_eq!(data, DECRYPTED_DATA.to_vec()); + Ok(EncryptedData { + metadata: Some(METADATA.to_vec()), + data: ENCRYPTED_DATA.to_vec(), + }) + } + + fn decrypt(&self, data: EncryptedData) -> Result, PubNubError> { + assert_eq!(data.metadata.unwrap(), METADATA.to_vec()); + assert_eq!(data.data, ENCRYPTED_DATA.to_vec()); + + Ok(DECRYPTED_DATA.to_vec()) + } + } + + #[test] + fn add_crypto_header_v1_data() { + let cryptor_module = CryptoModule::new(Box::new(MyCryptor), None); + let encrypt_result = cryptor_module.encrypt(DECRYPTED_DATA.to_vec()); + + let Ok(data) = encrypt_result else { + panic!("Encryption should be successful") + }; + + assert_eq!(data[0..4], *b"PNED"); + assert_eq!(data[4], 1); + assert_eq!(data[5..9], IDENTIFIER); + assert_eq!(data[9] as usize, METADATA.len()); + assert_eq!(data[10..(10 + data[9] as usize)], METADATA) + } + + #[test] + fn encrypt_data() { + let cryptor_module = CryptoModule::new(Box::new(MyCryptor), None); + let encrypt_result = cryptor_module.encrypt(DECRYPTED_DATA.to_vec()); + + let Ok(data) = encrypt_result else { + panic!("Encryption should be successful") + }; + + assert_eq!(data[(10 + data[9] as usize)..], ENCRYPTED_DATA) + } + + #[test] + fn decrypt_data() { + let mut encrypted_data: Vec = vec![]; + encrypted_data.extend(b"PNED"); + encrypted_data.push(1); + encrypted_data.extend(IDENTIFIER); + encrypted_data.push(METADATA.len() as u8); + encrypted_data.extend(METADATA); + encrypted_data.extend(ENCRYPTED_DATA); + + let cryptor_module = CryptoModule::new(Box::new(MyCryptor), None); + let decrypt_result = cryptor_module.decrypt(encrypted_data); + + let Ok(data) = decrypt_result else { + panic!("Decryption should be successful") + }; + + assert_eq!(data, DECRYPTED_DATA) + } + + #[test] + fn not_decrypt_data_with_unknown_cryptor() { + let mut encrypted_data: Vec = vec![]; + encrypted_data.extend(b"PNED"); + encrypted_data.push(1); + encrypted_data.extend(b"PNDC"); + encrypted_data.push(METADATA.len() as u8); + encrypted_data.extend(METADATA); + encrypted_data.extend(ENCRYPTED_DATA); + + let cryptor_module = CryptoModule::new(Box::new(MyCryptor), None); + let decrypt_result = cryptor_module.decrypt(encrypted_data); + + let Err(err) = decrypt_result else { + panic!("Decryption should not be successful") + }; + + assert!(matches!(err, PubNubError::UnknownCryptor { .. })) + } +} diff --git a/src/providers/crypto/cryptor_header.rs b/src/providers/crypto/cryptor_header.rs new file mode 100644 index 00000000..e3ecea33 --- /dev/null +++ b/src/providers/crypto/cryptor_header.rs @@ -0,0 +1,199 @@ +use crate::{ + core::PubNubError, + lib::alloc::{vec, vec::Vec}, +}; + +/// Maximum cryptor identifier length. +const IDENTIFIER_LENGTH: usize = 4; + +/// Legacy cryptor identifier. +const NULL_IDENTIFIER: &[u8; 4] = &[0x00u8; 4]; + +const SENTINEL: &[u8; 4] = b"PNED"; + +/// Maximum known header version. +/// +/// It should be possible to process headers up to this version. +const MAX_VERSION: u8 = 1; + +/// Cryptor data header. +/// +/// This instance used to parse header from received data and encode into binary +/// for sending. +#[derive(Debug)] +pub(crate) enum CryptorHeader { + V1 { + /// Unique cryptor identifier. + /// + /// Identifier of the cryptor which has been used to encrypt data. + identifier: [u8; 4], + + /// Size of cryptor-defined data. + data_size: usize, + }, + + /// Cryptor data doesn't have header. + None, +} + +impl CryptorHeader { + /// Create new crypto header. + pub fn new(identifier: [u8; 4], cryptor_metadata: &Option>) -> Self { + Self::V1 { + data_size: cryptor_metadata + .as_ref() + .map(|metadata| metadata.len()) + .unwrap_or(0), + identifier, + } + } + + /// Overall header size. + /// + /// Full header size which includes: + /// * sentinel + /// * version + /// * cryptor identifier + /// * cryptor data size + /// * cryptor-defined fields size. + pub fn len(&self) -> usize { + match self.data_size() { + Some(size) => { + SENTINEL.len() + 1 + IDENTIFIER_LENGTH + if size < 255 { 1 } else { 3 } + size + } + None => 0, + } + } + + /// Header version value. + pub fn version(&self) -> u8 { + match self { + CryptorHeader::V1 { .. } => 1, + _ => 0, + } + } + + /// Cryptor-defined data size. + pub fn data_size(&self) -> Option { + match self { + CryptorHeader::V1 { data_size, .. } => Some(*data_size), + _ => None, + } + } + + /// Cryptor identifier. + pub fn identifier(&self) -> Option<[u8; 4]> { + match self { + CryptorHeader::V1 { identifier, .. } => { + identifier.ne(NULL_IDENTIFIER).then_some(*identifier) + } + _ => None, + } + } +} + +/// Encode composed header into binary array. +impl From for Vec { + fn from(value: CryptorHeader) -> Self { + // Creating header only if specified. + let Some(identifier) = value.identifier() else { + return vec![]; + }; + + let mut data = vec![0; value.len()]; + // Adding sentinel into header. + data.splice(0..SENTINEL.len(), SENTINEL.to_vec()); + let mut pos = SENTINEL.len(); + + // Adding header version. + data[pos] = value.version(); + pos += 1; + + // Add cryptor identifier if it is not for legacy cryptor. + identifier.ne(NULL_IDENTIFIER).then(|| { + data.splice(pos..(pos + identifier.len()), identifier); + pos += identifier.len(); + }); + + // Adding cryptor header size. + let header_size = value.data_size().unwrap_or(0); + if header_size < 255 { + data[pos] = header_size as u8; + } else { + data.splice( + pos..(pos + 3), + vec![255, (header_size >> 8) as u8, (header_size & 0xFF) as u8], + ); + } + + data + } +} + +/// Decode and parse header from binary array. +impl TryFrom<&Vec> for CryptorHeader { + type Error = PubNubError; + + fn try_from(value: &Vec) -> Result { + // Data is too short to be encrypted. Assume legacy cryptor without + // header. + let Some(sentinel) = value.len().ge(&4).then(|| &value[0..4]) else { + return Ok(Self::None); + }; + + // There is no sentinel. Assume legacy cryptor without header. + if sentinel.ne(SENTINEL) { + return Ok(Self::None); + } + + let Some(version) = value.len().ge(&5).then(|| &value[4]) else { + return Err(PubNubError::Decryption { + details: "Decrypted data header is malformed.".into(), + }); + }; + + // Check whether version is within known range. + if *version == 0 || *version > MAX_VERSION { + return Err(PubNubError::UnknownCryptor { + details: "Decrypting data created by unknown cryptor.".into(), + }); + } + + // Retrieving cryptor identifier. + let mut pos = 5 + IDENTIFIER_LENGTH; + let Some(identifier) = value.len().ge(&pos).then(|| { + let mut identifier: [u8; 4] = [0; 4]; + identifier.copy_from_slice(&value[5..pos]); + identifier + }) else { + return Err(PubNubError::Decryption { + details: "Decrypted data header is malformed.".into(), + }); + }; + + // Retrieving cryptor-defined data size. + let Some(mut header_size) = value.len().ge(&(pos + 1)).then(|| value[pos] as usize) else { + return Ok(Self::None); + }; + pos += 1; + if header_size == 255 { + let Some(size_bytes) = value.len().ge(&(pos + 2)).then(|| { + let mut size_bytes: [u8; 2] = [0; 2]; + size_bytes.clone_from_slice(&value[pos..(pos + 2)]); + size_bytes + }) else { + return Ok(Self::None); + }; + header_size = u16::from_be_bytes(size_bytes) as usize; + } + + // Construct header basing on version passed in payload. + Ok(match version { + &1 => Self::V1 { + data_size: header_size, + identifier, + }, + _ => Self::None, + }) + } +} diff --git a/src/providers/crypto/cryptors/aes_cbc.rs b/src/providers/crypto/cryptors/aes_cbc.rs new file mode 100644 index 00000000..aa2468b6 --- /dev/null +++ b/src/providers/crypto/cryptors/aes_cbc.rs @@ -0,0 +1,205 @@ +//! # AES-CBC cryptor module. +//! +//! Module contains [`AesCbcCryptor`] type which can be used for data encryption +//! and decryption. + +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use sha2::{Digest, Sha256}; + +use crate::{ + core::{Cryptor, EncryptedData, PubNubError}, + lib::alloc::{format, string::ToString, vec, vec::Vec}, +}; + +type Encryptor = cbc::Encryptor; +/// AES-SHA256 encryptor type. +type Decryptor = cbc::Decryptor; + +/// Unique cryptor identifier +const IDENTIFIER: [u8; 4] = *b"ACRH"; + +/// AES cipher block size. +const AES_BLOCK_SIZE: usize = 16; + +/// AES-CBC cryptor. +#[derive(Debug)] +pub struct AesCbcCryptor { + /// Key for data _encryption_ / _decryption_. + cipher_key: Vec, +} + +impl AesCbcCryptor { + /// Create AES-CBC cryptor. + pub fn new(cipher_key: K) -> Result + where + K: Into>, + { + let cipher_key = cipher_key.into(); + + if cipher_key.is_empty() { + return Err(PubNubError::CryptoInitialization { + details: "Cipher key is empty".into(), + }); + } + + Ok(Self { + cipher_key: Self::sha256(cipher_key), + }) + } + + fn initialization_vector(&self) -> [u8; AES_BLOCK_SIZE] { + let mut random = [0u8; AES_BLOCK_SIZE]; + getrandom::getrandom(&mut random).ok(); + random + } + + fn estimated_enc_buffer_size(&self, source: &[u8]) -> usize { + // Adding padding which include additional AES cipher block size. + source.len() + (AES_BLOCK_SIZE - source.len() % AES_BLOCK_SIZE) + AES_BLOCK_SIZE + } + + fn estimated_dec_buffer_size(&self, source: &[u8]) -> usize { + source.len() + } + + fn sha256(data: Vec) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(data.as_slice()); + hasher.finalize().to_vec() + } +} + +impl Cryptor for AesCbcCryptor { + fn identifier(&self) -> [u8; 4] { + IDENTIFIER + } + + fn encrypt(&self, data: Vec) -> Result { + if data.is_empty() { + return Err(PubNubError::Encryption { + details: "Encrypted data is empty".into(), + }); + } + + let mut buffer = vec![0u8; self.estimated_enc_buffer_size(&data)]; + let iv = self.initialization_vector(); + + let result = Encryptor::new(self.cipher_key.as_slice().into(), iv.as_slice().into()) + .encrypt_padded_b2b_mut::(&data, &mut buffer) + .map_err(|err| PubNubError::Encryption { + details: err.to_string(), + })?; + let encrypted_len = result.len(); + + // Adjust size of buffer to actual processed data length. + buffer.resize(encrypted_len, 0); + + Ok(EncryptedData { + metadata: Some(iv.to_vec()), + data: buffer, + }) + } + + fn decrypt(&self, data: EncryptedData) -> Result, PubNubError> { + let mut buffer = vec![0u8; self.estimated_dec_buffer_size(&data.data)]; + let Some(iv) = data.metadata else { + return Err(PubNubError::Decryption { + details: "Initialization vector is missing from payload".into(), + }); + }; + + if iv.len().ne(&AES_BLOCK_SIZE) { + return Err(PubNubError::Decryption { + details: format!( + "Unexpected initialization vector size: {} bytes ({} bytes is expected)", + iv.len(), + AES_BLOCK_SIZE + ), + }); + } + + let result = Decryptor::new(self.cipher_key.as_slice().into(), iv.as_slice().into()) + .decrypt_padded_b2b_mut::(&data.data, buffer.as_mut()) + .map_err(|err| PubNubError::Decryption { + details: err.to_string(), + })?; + + // Adjust size of buffer to actual processed data length. + let decrypted_len = result.len(); + buffer.resize(decrypted_len, 0); + + Ok(buffer) + } +} + +#[cfg(test)] +mod it_should { + use super::*; + use base64::{engine::general_purpose, Engine as _}; + + #[test] + fn create_cryptor() { + let cryptor = AesCbcCryptor::new("enigma").expect("Cryptor should be created"); + assert_ne!( + cryptor.initialization_vector(), + cryptor.initialization_vector() + ); + } + + #[test] + fn not_create_cryptor_with_empty_cipher_key() { + let cryptor = AesCbcCryptor::new(""); + assert!(cryptor.is_err()); + } + + #[test] + fn encrypt_data() { + // Prefix includes potential header size. + let prefix_size = AES_BLOCK_SIZE + 10; + let cryptor = AesCbcCryptor::new("enigma").expect("Cryptor should be created"); + let encrypted1 = cryptor + .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) + .expect("Data should be encrypted") + .data; + let encrypted2 = cryptor + .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) + .expect("Data should be encrypted") + .data; + assert_ne!(encrypted1, encrypted2); + assert_ne!(encrypted1[0..prefix_size], encrypted2[0..prefix_size]); + } + + #[test] + fn decrypt_data() { + let header_offset = 10; + let encrypted1 = general_purpose::STANDARD + .decode( + "UE5FRAFBQ1JIELzZwCmyT4vQLcjIAf8hSX2/mRRRby+egFPTqmwSKIFcjI1V/ig/y3M1iTlwknrTSw==", + ) + .expect("Valid base64 encoded string required."); + let encrypted2 = general_purpose::STANDARD + .decode( + "UE5FRAFBQ1JIECL8XmJWRSElf8c7ykQfMcnLQVc+Mta7ln3jcF7bHNmCk4nKMoyhPN19oMO5uVPxSA==", + ) + .expect("Valid base64 encoded string required."); + let cryptor = AesCbcCryptor::new("enigma").expect("Cryptor should be created"); + let decrypted1 = cryptor + .decrypt(EncryptedData { + metadata: Some( + encrypted1[header_offset..(header_offset + AES_BLOCK_SIZE)].to_vec(), + ), + data: encrypted1[(header_offset + AES_BLOCK_SIZE)..].to_vec(), + }) + .expect("Data should be decrypted 1"); + let decrypted2 = cryptor + .decrypt(EncryptedData { + metadata: Some( + encrypted2[header_offset..(header_offset + AES_BLOCK_SIZE)].to_vec(), + ), + data: encrypted2[(header_offset + AES_BLOCK_SIZE)..].to_vec(), + }) + .expect("Data should be decrypted 2"); + assert_eq!(decrypted1, "\"Hello there πŸ™ƒ\"".as_bytes()); + assert_eq!(decrypted1, decrypted2); + } +} diff --git a/src/providers/crypto/cryptors/legacy.rs b/src/providers/crypto/cryptors/legacy.rs new file mode 100644 index 00000000..725dc8fa --- /dev/null +++ b/src/providers/crypto/cryptors/legacy.rs @@ -0,0 +1,302 @@ +//! # Legacy AES-CBC cryptor module. +//! +//! Module contains [`LegacyCryptor`] type which can be used for data encryption +//! and decryption. + +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use sha2::{Digest, Sha256}; + +use crate::{ + core::{Cryptor, EncryptedData, PubNubError}, + lib::alloc::{ + format, + string::{String, ToString}, + vec, + vec::Vec, + }, +}; + +type Encryptor = cbc::Encryptor; +/// AES-SHA256 encryptor type. +type Decryptor = cbc::Decryptor; + +/// Unique cryptor identifier +const IDENTIFIER: [u8; 4] = [0x00u8; 4]; + +/// AES cipher block size. +const AES_BLOCK_SIZE: usize = 16; + +/// Legacy cryptor. +/// +/// Legacy AES-CBC cryptor which let use random or hardcoded initialization +/// vector and key with low entropy issue. +#[derive(Debug)] +pub struct LegacyCryptor { + /// Whether random IV should be used. + /// + /// With enabled random IV it will become part of cryptor-defined fields. + use_random_iv: bool, + + /// Key for data encryption / decryption + cipher_key: Vec, +} + +impl LegacyCryptor { + /// Create Legacy AES-CBC cryptor. + pub fn new(cipher_key: K, use_random_iv: bool) -> Result + where + K: Into>, + { + let cipher_key = cipher_key.into(); + + if cipher_key.is_empty() { + return Err(PubNubError::CryptoInitialization { + details: "Cipher key is empty".into(), + }); + } + + Ok(Self { + use_random_iv, + cipher_key: Self::sha256_hex(cipher_key), + }) + } + + fn initialization_vector(&self) -> [u8; 16] { + if self.use_random_iv { + let mut random = [0u8; AES_BLOCK_SIZE]; + getrandom::getrandom(&mut random).ok(); + random + } else { + *b"0123456789012345" + } + } + + fn estimated_enc_buffer_size(&self, source: &[u8]) -> usize { + // Adding padding which include additional AES cipher block size. + let padding = (AES_BLOCK_SIZE - source.len() % AES_BLOCK_SIZE) + AES_BLOCK_SIZE; + if self.use_random_iv { + // Reserve more space to store random initialization vector. + source.len() + padding + AES_BLOCK_SIZE + } else { + source.len() + padding + } + } + + fn estimated_dec_buffer_size(&self, source: &[u8]) -> usize { + // Subtract size of random initialization vector (if used). + source.len() + - if self.use_random_iv { + AES_BLOCK_SIZE + } else { + 0 + } + } + + fn sha256_hex(data: Vec) -> Vec { + let mut hasher = Sha256::new(); + hasher.update(data.as_slice()); + hasher + .finalize() + .iter() + .take(AES_BLOCK_SIZE) + .fold(String::new(), |acc, byte| format!("{}{:02x}", acc, byte)) + .into_bytes() + } +} + +impl Cryptor for LegacyCryptor { + fn identifier(&self) -> [u8; 4] { + IDENTIFIER + } + + fn encrypt(&self, data: Vec) -> Result { + if data.is_empty() { + return Err(PubNubError::Encryption { + details: "Encrypted data is empty".into(), + }); + } + + let mut buffer = vec![0u8; self.estimated_enc_buffer_size(&data)]; + let data_offset = if self.use_random_iv { + AES_BLOCK_SIZE + } else { + 0 + }; + let data_slice = &mut buffer[data_offset..]; + let iv = self.initialization_vector(); + + let result = Encryptor::new(self.cipher_key.as_slice().into(), iv.as_slice().into()) + .encrypt_padded_b2b_mut::(&data, data_slice) + .map_err(|err| PubNubError::Encryption { + details: err.to_string(), + })?; + let encrypted_len = result.len() + data_offset; + + // Prepend random initialization vector to encrypted data if required. + if data_offset > 0 { + buffer[0..data_offset].copy_from_slice(iv.as_slice()); + } + + // Adjust size of buffer to actual processed data length. + buffer.resize(encrypted_len, 0); + + Ok(EncryptedData { + metadata: None, + data: buffer, + }) + } + + fn decrypt(&self, data: EncryptedData) -> Result, PubNubError> { + let mut buffer = vec![0u8; self.estimated_dec_buffer_size(&data.data)]; + let data_offset = if self.use_random_iv { + AES_BLOCK_SIZE + } else { + 0 + }; + let iv = if self.use_random_iv { + if data.data.len() >= AES_BLOCK_SIZE { + data.data[0..AES_BLOCK_SIZE].to_vec() + } else { + vec![] + } + } else { + self.initialization_vector().to_vec() + }; + + if iv.len().ne(&AES_BLOCK_SIZE) { + return Err(PubNubError::Decryption { + details: format!( + "Unexpected initialization vector size: {} bytes ({} bytes is expected)", + iv.len(), + AES_BLOCK_SIZE + ), + }); + } + + let data_slice = &data.data[data_offset..]; + + if data_slice.is_empty() { + return Err(PubNubError::Decryption { + details: "Decrypted data is empty.".into(), + }); + } + + let result = Decryptor::new(self.cipher_key.as_slice().into(), iv.as_slice().into()) + .decrypt_padded_b2b_mut::(data_slice, buffer.as_mut()) + .map_err(|err| PubNubError::Decryption { + details: err.to_string(), + })?; + + // Adjust size of buffer to actual processed data length. + let decrypted_len = result.len(); + buffer.resize(decrypted_len, 0); + + Ok(buffer) + } +} + +#[cfg(test)] +mod it_should { + use super::*; + use base64::{engine::general_purpose, Engine as _}; + + #[test] + fn create_cryptor_with_hardcoded_iv() { + let cryptor = LegacyCryptor::new("enigma", false).expect("Cryptor should be created"); + let iv = cryptor.initialization_vector(); + assert_eq!(&iv, b"0123456789012345"); + assert_eq!( + cryptor.initialization_vector(), + cryptor.initialization_vector() + ); + } + + #[test] + fn create_cryptor_with_random_iv() { + let cryptor = LegacyCryptor::new("enigma", true).expect("Cryptor should be created"); + assert_ne!( + cryptor.initialization_vector(), + cryptor.initialization_vector() + ); + } + + #[test] + fn not_create_cryptor_with_empty_cipher_key() { + let cryptor = LegacyCryptor::new("", true); + assert!(cryptor.is_err()); + } + + #[test] + fn encrypt_data_with_constant_iv() { + let cryptor = LegacyCryptor::new("enigma", false).expect("Cryptor should be created"); + let encrypted1 = cryptor + .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) + .expect("Data should be encrypted") + .data; + let encrypted2 = cryptor + .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) + .expect("Data should be encrypted") + .data; + assert_eq!(encrypted1, encrypted2); + assert_ne!(b"0123456789012345", &encrypted1[0..AES_BLOCK_SIZE]); + assert_eq!( + general_purpose::STANDARD.encode(encrypted2), + "4K7StI9dRz7utFsDHvuy082CQupbJvdwzrRja47qAV4=" + ); + } + + #[test] + fn encrypt_data_with_random_iv() { + let cryptor = LegacyCryptor::new("enigma", true).expect("Cryptor should be created"); + let encrypted1 = cryptor + .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) + .expect("Data should be encrypted") + .data; + let encrypted2 = cryptor + .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) + .expect("Data should be encrypted") + .data; + assert_ne!(encrypted1, encrypted2); + assert_ne!(encrypted1[0..AES_BLOCK_SIZE], encrypted2[0..AES_BLOCK_SIZE]); + } + + #[test] + fn decrypt_data_with_constant_iv() { + let encrypted = general_purpose::STANDARD + .decode("4K7StI9dRz7utFsDHvuy082CQupbJvdwzrRja47qAV4=") + .expect("Valid base64 encoded string required."); + let cryptor = LegacyCryptor::new("enigma", false).expect("Cryptor should be created"); + let decrypted = cryptor + .decrypt(EncryptedData { + metadata: None, + data: encrypted, + }) + .expect("Data should be decrypted"); + assert_eq!(decrypted, "\"Hello there πŸ™ƒ\"".as_bytes()); + } + + #[test] + fn decrypt_data_with_random_iv() { + let encrypted1 = general_purpose::STANDARD + .decode("fRm/rMArHgQuIuhuJMbXV8JLOUqf5sP72lGC4EaW98nNhmJltQcmCol9XXWgeDJC") + .expect("Valid base64 encoded string required."); + let encrypted2 = general_purpose::STANDARD + .decode("gk6glnaeb+8zeEvZR1q3sHyQV7xTo1pNf4cc4uJF+a2bK1fMY816Hc9I6j+gYR+5") + .expect("Valid base64 encoded string required."); + let cryptor = LegacyCryptor::new("enigma", true).expect("Cryptor should be created"); + let decrypted1 = cryptor + .decrypt(EncryptedData { + metadata: None, + data: encrypted1, + }) + .expect("Data should be decrypted"); + let decrypted2 = cryptor + .decrypt(EncryptedData { + metadata: None, + data: encrypted2, + }) + .expect("Data should be decrypted"); + assert_eq!(decrypted1, "\"Hello there πŸ™ƒ\"".as_bytes()); + assert_eq!(decrypted1, decrypted2); + } +} diff --git a/src/providers/crypto/cryptors/mod.rs b/src/providers/crypto/cryptors/mod.rs new file mode 100644 index 00000000..aa560966 --- /dev/null +++ b/src/providers/crypto/cryptors/mod.rs @@ -0,0 +1,17 @@ +//! Cryptors module +//! +//! The module provides [`Cryptor`] trait implementations: +//! * [`LegacyCryptor`] +//! * [`AesCbcCryptor`] +//! +//! Actual implementations can be used to configure [`CryptoProvider`] +//! implementations for standalone usage or as part of [`PubNubClientInstance`] +//! for automated data _encryption_ and _decryption_. + +#[doc(inline)] +pub use legacy::LegacyCryptor; +pub mod legacy; + +#[doc(inline)] +pub use aes_cbc::AesCbcCryptor; +pub mod aes_cbc; diff --git a/src/providers/crypto/mod.rs b/src/providers/crypto/mod.rs new file mode 100644 index 00000000..605b512f --- /dev/null +++ b/src/providers/crypto/mod.rs @@ -0,0 +1,98 @@ +//! Crypto module +//! +//! This module contains a [`CryptoModule`] which allows to handle encrypted +//! data in backward compatible way. [`AesCbcCryptor`] and [`LegacyCryptor`] +//! cryptors available for [`CryptoModule`] configuration for data _encryption_ +//! and _decryption_. + +#[doc(inline)] +pub use cryptors::{AesCbcCryptor, LegacyCryptor}; +pub mod cryptors; + +#[doc(inline)] +pub use crypto_module::CryptoModule; +pub mod crypto_module; + +#[doc(inline)] +pub(crate) use cryptor_header::CryptorHeader; +pub(crate) mod cryptor_header; + +use crate::{ + core::PubNubError, + lib::alloc::{boxed::Box, vec, vec::Vec}, +}; + +/// [`CryptoModule`] module extension with convenience methods. +impl CryptoModule { + /// AES-CBC cryptor based module. + /// + /// Data _encryption_ and _decryption_ will be done by default using the + /// [`AesCbcCryptor`]. In addition to the [`AesCbcCryptor`] for data + /// _decryption_, the [`LegacyCryptor`] will be registered for + /// backward-compatibility. + /// + /// Returns error if `cipher_key` is empty. + pub fn new_aes_cbc_module(cipher_key: K, use_random_iv: bool) -> Result + where + K: Into>, + { + let cipher_key = cipher_key.into(); + + if cipher_key.is_empty() { + return Err(PubNubError::CryptoInitialization { + details: "Cipher key is empty".into(), + }); + } + + Ok(Self::new( + Box::new(AesCbcCryptor::new(cipher_key.clone())?), + Some(vec![Box::new(LegacyCryptor::new( + cipher_key, + use_random_iv, + )?)]), + )) + } + + /// Legacy AES-CBC cryptor based module. + /// + /// Data _encryption_ and _decryption_ will be done by default using the + /// [`LegacyCryptor`]. In addition to the [`LegacyCryptor`] for data + /// _decryption_, the [`AesCbcCryptor`] will be registered for + /// future-compatibility (which will help with gradual application updates). + /// + /// Returns error if `cipher_key` is empty. + pub fn new_legacy_module(cipher_key: K, use_random_iv: bool) -> Result + where + K: Into>, + { + let cipher_key = cipher_key.into(); + + if cipher_key.is_empty() { + return Err(PubNubError::CryptoInitialization { + details: "Cipher key is empty".into(), + }); + } + + Ok(Self::new( + Box::new(LegacyCryptor::new(cipher_key.clone(), use_random_iv)?), + Some(vec![Box::new(AesCbcCryptor::new(cipher_key)?)]), + )) + } +} + +#[cfg(test)] +mod it_should { + use super::*; + + #[test] + fn not_create_legacy_module_with_empty_cipher_key() { + let crypto_module = CryptoModule::new_legacy_module("", false); + assert!(crypto_module.is_err()); + } + + #[test] + fn not_create_aes_cbc_module_with_empty_cipher_key() { + let crypto_module = CryptoModule::new_aes_cbc_module("", false); + assert!(crypto_module.is_err()); + } +} diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 761992c3..f79439ad 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -12,8 +12,8 @@ pub mod serialization_serde; #[cfg(feature = "serde")] pub mod deserialization_serde; -#[cfg(feature = "aescbc")] -pub mod crypto_aescbc; +#[cfg(feature = "crypto")] +pub mod crypto; #[cfg(all(feature = "tokio", feature = "std"))] pub mod futures_tokio; diff --git a/tests/common/common_steps.rs b/tests/common/common_steps.rs index bfbc371a..38f0b5a3 100644 --- a/tests/common/common_steps.rs +++ b/tests/common/common_steps.rs @@ -108,6 +108,31 @@ impl Default for PAMState { } } +#[derive(Debug)] +pub struct CryptoModuleState { + pub crypto_identifiers: Vec, + pub legacy: Option, + pub file_content: Vec, + pub cipher_key: Option, + pub use_random_iv: bool, + pub encryption_result: Option, PubNubError>>, + pub decryption_result: Option, PubNubError>>, +} + +impl Default for CryptoModuleState { + fn default() -> Self { + Self { + crypto_identifiers: vec![], + legacy: None, + file_content: vec![], + cipher_key: None, + use_random_iv: true, + encryption_result: None, + decryption_result: None, + } + } +} + #[derive(Debug, World)] pub struct PubNubWorld { pub scenario: Option, @@ -116,6 +141,7 @@ pub struct PubNubWorld { pub subscription: Result, pub retry_policy: Option, pub pam_state: PAMState, + pub crypto_state: CryptoModuleState, pub api_error: Option, pub is_succeed: bool, } @@ -139,6 +165,7 @@ impl Default for PubNubWorld { }), is_succeed: false, pam_state: PAMState::default(), + crypto_state: CryptoModuleState::default(), api_error: None, retry_policy: None, } diff --git a/tests/contract_test.rs b/tests/contract_test.rs index 77b96fc9..7afb8f69 100644 --- a/tests/contract_test.rs +++ b/tests/contract_test.rs @@ -4,6 +4,7 @@ use std::process; mod access; mod common; +mod crypto; mod publish; mod subscribe; use common::PubNubWorld; @@ -23,7 +24,7 @@ fn get_feature_set(tags: &[String]) -> String { } fn feature_allows_beta(feature: &str) -> bool { - let features: Vec<&str> = vec!["access", "publish", "eventEngine"]; + let features: Vec<&str> = vec!["access", "publish", "eventEngine", "cryptoModule"]; features.contains(&feature) } @@ -33,12 +34,12 @@ fn feature_allows_skipped(feature: &str) -> bool { } fn feature_allows_contract_less(feature: &str) -> bool { - let features: Vec<&str> = vec!["access"]; + let features: Vec<&str> = vec!["access", "cryptoModule"]; features.contains(&feature) } fn is_ignored_feature_set_tag(feature: &str, tags: &[String]) -> bool { - let supported_features = ["access", "publish", "eventEngine"]; + let supported_features = ["access", "publish", "eventEngine", "cryptoModule"]; let mut ignored_tags = vec!["na=rust"]; if !feature_allows_beta(feature) { diff --git a/tests/crypto/crypto_steps.rs b/tests/crypto/crypto_steps.rs new file mode 100644 index 00000000..91bb6a99 --- /dev/null +++ b/tests/crypto/crypto_steps.rs @@ -0,0 +1,181 @@ +use crate::common::PubNubWorld; +use cucumber::{given, then, when}; +use std::fs::File; +use std::io::Read; + +use pubnub::core::{CryptoProvider, Cryptor, PubNubError}; +use pubnub::providers::crypto::{AesCbcCryptor, CryptoModule, LegacyCryptor}; + +#[given(regex = r#"^Crypto module with '(.*)' cryptor$"#)] +fn given_cryptor_with_id(world: &mut PubNubWorld, cryptor_id: String) { + world.crypto_state.crypto_identifiers.push(cryptor_id) +} + +#[given(regex = r#"^Crypto module with default '(.*)' and additional '(.*)' cryptors$"#)] +fn given_cryptor_with_multiple_id( + world: &mut PubNubWorld, + default_crypto_id: String, + secondary_crypto_id: String, +) { + world + .crypto_state + .crypto_identifiers + .push(default_crypto_id); + world + .crypto_state + .crypto_identifiers + .push(secondary_crypto_id); +} + +#[given(regex = r#"^with '(.*)' cipher key$"#)] +fn given_cipher_key(world: &mut PubNubWorld, cipher_key: String) { + world.crypto_state.cipher_key = Some(cipher_key) +} + +#[given(regex = r#"^with '(constant|random|-)' vector$"#)] +fn given_iv_type(world: &mut PubNubWorld, vector_type: String) { + world.crypto_state.use_random_iv = vector_type.eq("random") +} + +#[given(regex = r#"^Legacy code with '(.*)' cipher key and '(constant|random|-)' vector$"#)] +fn given_legacy_code(world: &mut PubNubWorld, cipher_key: String, vector_type: String) { + use super::super::crypto::legacy::{AesCbcCrypto, AesCbcIv}; + + let iv = if vector_type.eq("constant") { + AesCbcIv::Constant + } else { + AesCbcIv::Random + }; + + world.crypto_state.legacy = + Some(AesCbcCrypto::new(cipher_key, iv).expect("Should create legacy crypto")); +} + +#[when(regex = r#"^I encrypt '(.*)' file as 'binary'$"#)] +fn when_encrypt_data(world: &mut PubNubWorld, file_name: String) { + let cryptor_module = cryptor_module(world); + world.crypto_state.file_content = load_file_with_name(file_name); + world.crypto_state.encryption_result = + Some(cryptor_module.encrypt(world.crypto_state.file_content.clone())); +} + +#[when(regex = r#"^I decrypt '(.*)' file$"#)] +#[when(regex = r#"^I decrypt '(.*)' file as 'binary'$"#)] +fn when_decrypt_data(world: &mut PubNubWorld, file_name: String) { + let cryptor_module = cryptor_module(world); + let file_content = load_file_with_name(file_name); + world.crypto_state.decryption_result = Some(cryptor_module.decrypt(file_content)); +} + +#[then(regex = r#"^I receive '(.*)'$"#)] +fn then_receive_outcome(world: &mut PubNubWorld, outcome: String) { + let result = if world.crypto_state.encryption_result.is_some() { + world.crypto_state.encryption_result.clone() + } else { + world.crypto_state.decryption_result.clone() + }; + + let Some(result) = result else { + panic!("Expected to have result of encryption or decryption operation"); + }; + + match outcome.as_str() { + "unknown cryptor error" => { + assert!(result.is_err(), "Operation should fail: {result:?}"); + assert!(matches!( + result.err().unwrap(), + PubNubError::UnknownCryptor { .. } + )) + } + "decryption error" => { + assert!(result.is_err(), "Operation should fail: {result:?}"); + assert!(matches!( + result.err().unwrap(), + PubNubError::Decryption { .. } + )) + } + "encryption error" => { + assert!(result.is_err(), "Operation should fail: {result:?}"); + assert!(matches!( + result.err().unwrap(), + PubNubError::Encryption { .. } + )) + } + "success" => assert!(result.is_ok(), "Operation should be successful"), + _ => panic!("Unknown outcome in feature file."), + } +} + +#[then("Successfully decrypt an encrypted file with legacy code")] +fn then_success_decrypt(world: &mut PubNubWorld) { + use crate::crypto::legacy::cryptor::Cryptor; + + let Some(result) = &world.crypto_state.encryption_result else { + panic!("No content has been encrypted") + }; + + assert!(result.is_ok()); + + let decrypt_result = world + .crypto_state + .legacy + .as_ref() + .unwrap() + .decrypt(result.clone().ok().unwrap()) + .expect("Should decrypt without error"); + + assert_eq!(decrypt_result, world.crypto_state.file_content); +} + +#[then(regex = r#"^Decrypted file content equal to the '(.*)' file content$"#)] +fn then_decrypt_data(world: &mut PubNubWorld, file_name: String) { + let Some(result) = &world.crypto_state.decryption_result else { + panic!("No content has been encrypted") + }; + + assert!(result.is_ok()); + assert_eq!(result.clone().ok().unwrap(), load_file_with_name(file_name)); +} + +fn load_file_with_name(file_name: String) -> Vec { + let file_path = format!("tests/features/encryption/assets/{file_name}"); + let mut file = File::open(file_path).expect("File should exist!"); + let mut content: Vec = vec![]; + file.read_to_end(&mut content) + .expect("Unable to read content"); + + content +} + +fn cryptor_module(world: &PubNubWorld) -> CryptoModule { + let mut cryptors = world + .crypto_state + .crypto_identifiers + .iter() + .map(|id| { + if id.eq("acrh") { + Box::new( + AesCbcCryptor::new(world.crypto_state.cipher_key.clone().unwrap()) + .expect("Cryptor should initialize"), + ) as Box + } else { + Box::new( + LegacyCryptor::new( + world.crypto_state.cipher_key.clone().unwrap(), + world.crypto_state.use_random_iv, + ) + .expect("Cryptor should initialize"), + ) as Box + } + }) + .collect::>>(); + + CryptoModule::new( + cryptors.remove(0), + if cryptors.len() > 0 { + Some(cryptors) + } else { + None + }, + ) +} diff --git a/src/providers/crypto_aescbc.rs b/tests/crypto/legacy/crypto_aescbc.rs similarity index 71% rename from src/providers/crypto_aescbc.rs rename to tests/crypto/legacy/crypto_aescbc.rs index 59c12120..a537b1c9 100644 --- a/src/providers/crypto_aescbc.rs +++ b/tests/crypto/legacy/crypto_aescbc.rs @@ -10,14 +10,8 @@ //! [`aes`]: https://crates.io/crates/aes //! [`cbc`]: https://crates.io/crates/cbc //! [`aescbc` feature]: ../index.html#features -use crate::core::{error::PubNubError, Cryptor}; -use crate::lib::alloc::{ - format, - string::{String, ToString}, - vec, - vec::Vec, -}; use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use pubnub::core::error::PubNubError; use sha2::{Digest, Sha256}; /// AES-SHA256 encryptor type. @@ -190,7 +184,7 @@ impl AesCbcCrypto { } } -impl Cryptor for AesCbcCrypto { +impl super::cryptor::Cryptor for AesCbcCrypto { /// Encrypt provided data. /// /// # Examples @@ -298,103 +292,3 @@ impl Cryptor for AesCbcCrypto { Ok(buffer) } } - -#[cfg(test)] -mod it_should { - use super::*; - use base64::{engine::general_purpose, Engine as _}; - - #[test] - fn create_cryptor_with_hardcoded_iv() { - let cryptor = - AesCbcCrypto::new("enigma", AesCbcIv::Constant).expect("Cryptor should be created"); - let iv = cryptor - .iv - .clone() - .expect("Initialization vector should be created"); - assert_eq!(iv, "0123456789012345".as_bytes().to_vec()); - assert_eq!(cryptor.encryption_iv(), cryptor.encryption_iv()); - } - - #[test] - fn create_cryptor_with_random_iv() { - let cryptor = - AesCbcCrypto::new("enigma", AesCbcIv::Random).expect("Cryptor should be created"); - assert!(cryptor.iv.is_none()); - assert_ne!(cryptor.encryption_iv(), cryptor.encryption_iv()); - } - - #[test] - fn not_create_cryptor_with_empty_cipher_key() { - let cryptor = AesCbcCrypto::new("", AesCbcIv::Random); - assert!(cryptor.is_err()); - } - - #[test] - fn encrypt_data_with_constant_iv() { - let cryptor = - AesCbcCrypto::new("enigma", AesCbcIv::Constant).expect("Cryptor should be created"); - let encrypted1 = cryptor - .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) - .expect("Data should be encrypted"); - let encrypted2 = cryptor - .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) - .expect("Data should be encrypted"); - assert_eq!(encrypted1, encrypted2); - assert_ne!( - "0123456789012345".as_bytes(), - &encrypted1[0..AES_BLOCK_SIZE] - ); - assert_eq!( - general_purpose::STANDARD.encode(encrypted2), - "4K7StI9dRz7utFsDHvuy082CQupbJvdwzrRja47qAV4=" - ); - } - - #[test] - fn encrypt_data_with_random_iv() { - let cryptor = - AesCbcCrypto::new("enigma", AesCbcIv::Random).expect("Cryptor should be created"); - let encrypted1 = cryptor - .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) - .expect("Data should be encrypted"); - let encrypted2 = cryptor - .encrypt(Vec::from("\"Hello there πŸ™ƒ\"")) - .expect("Data should be encrypted"); - assert_ne!(encrypted1, encrypted2); - assert_ne!(encrypted1[0..AES_BLOCK_SIZE], encrypted2[0..AES_BLOCK_SIZE]); - } - - #[test] - fn decrypt_data_with_constant_iv() { - let encrypted = general_purpose::STANDARD - .decode("4K7StI9dRz7utFsDHvuy082CQupbJvdwzrRja47qAV4=") - .expect("Valid base64 encoded string required."); - let cryptor = - AesCbcCrypto::new("enigma", AesCbcIv::Constant).expect("Cryptor should be created"); - let decrypted = cryptor - .decrypt(encrypted) - .expect("Data should be decrypted"); - assert_eq!(decrypted, "\"Hello there πŸ™ƒ\"".as_bytes()); - } - - #[test] - fn decrypt_data_with_random_iv() { - let encrypted1 = general_purpose::STANDARD - .decode("fRm/rMArHgQuIuhuJMbXV8JLOUqf5sP72lGC4EaW98nNhmJltQcmCol9XXWgeDJC") - .expect("Valid base64 encoded string required."); - let encrypted2 = general_purpose::STANDARD - .decode("gk6glnaeb+8zeEvZR1q3sHyQV7xTo1pNf4cc4uJF+a2bK1fMY816Hc9I6j+gYR+5") - .expect("Valid base64 encoded string required."); - let cryptor = - AesCbcCrypto::new("enigma", AesCbcIv::Random).expect("Cryptor should be created"); - let decrypted1 = cryptor - .decrypt(encrypted1) - .expect("Data should be decrypted"); - let decrypted2 = cryptor - .decrypt(encrypted2) - .expect("Data should be decrypted"); - assert_eq!(decrypted1, "\"Hello there πŸ™ƒ\"".as_bytes()); - assert_eq!(decrypted1, decrypted2); - } -} diff --git a/tests/crypto/legacy/cryptor.rs b/tests/crypto/legacy/cryptor.rs new file mode 100644 index 00000000..b36cc75b --- /dev/null +++ b/tests/crypto/legacy/cryptor.rs @@ -0,0 +1,59 @@ +//! Cryptor module +//! +//! This module contains the [`Cryptor`] trait which is used to implement +//! encryption and decryption of published data. + +use pubnub::core::error::PubNubError; +use std::fmt::Debug; + +/// This trait is used to encrypt and decrypt messages sent to the +/// [`PubNub API`]. +/// +/// It is used by the [`dx`] modules to encrypt messages sent to PubNub and +/// returned by the [`PubNub API`]. +/// +/// To implement this trait, you must provide `encrypt` and `decrypt` methods +/// that takes a `&[u8]` and returns a `Result, PubNubError>`. +/// +/// You can implement this trait for your own types, or use one of the provided +/// features to use a crypto library. +/// When you use this trait to make your own crypto, make sure that other SDKs +/// use the same encryption and decryption algorithms. +/// +/// # Examples +/// ``` +/// use pubnub::core::{Cryptor, error::PubNubError}; +/// +/// #[derive(Debug)] +/// struct MyCryptor; +/// +/// impl Cryptor for MyCryptor { +/// fn encrypt(&self, source: Vec) -> Result, PubNubError> { +/// // Encrypt provided data here +/// Ok(vec![]) +/// } +/// +/// fn decrypt(&self, source: Vec) -> Result, PubNubError> { +/// // Decrypt provided data here +/// Ok(vec![]) +/// } +/// } +/// ``` +/// +/// [`dx`]: ../dx/index.html +/// [`PubNub API`]: https://www.pubnub.com/docs +pub trait Cryptor: Debug + Send + Sync { + /// Decrypt provided data. + /// + /// # Errors + /// Should return an [`PubNubError::Encryption`] if provided data can't + /// be encrypted or underlying cryptor misconfigured. + fn encrypt(&self, source: Vec) -> Result, PubNubError>; + + /// Decrypt provided data. + /// + /// # Errors + /// Should return an [`PubNubError::Decryption`] if provided data can't + /// be decrypted or underlying cryptor misconfigured. + fn decrypt(&self, source: Vec) -> Result, PubNubError>; +} diff --git a/tests/crypto/legacy/mod.rs b/tests/crypto/legacy/mod.rs new file mode 100644 index 00000000..4a6751d1 --- /dev/null +++ b/tests/crypto/legacy/mod.rs @@ -0,0 +1,4 @@ +pub mod cryptor; + +pub use crypto_aescbc::{AesCbcCrypto, AesCbcIv}; +pub mod crypto_aescbc; diff --git a/tests/crypto/mod.rs b/tests/crypto/mod.rs new file mode 100644 index 00000000..7837a0a4 --- /dev/null +++ b/tests/crypto/mod.rs @@ -0,0 +1,3 @@ +mod crypto_steps; + +pub mod legacy;