From 871bb393784e92b8684edd7546d1ffb31b0c975d Mon Sep 17 00:00:00 2001 From: Weiliang Li Date: Fri, 21 Jul 2023 00:25:21 +0900 Subject: [PATCH] Revamp configuration and add xchacha20-poly1305 backend (#104) Co-authored-by: drp4rad0x <87465832+drp4rad0x@users.noreply.github.com> --- .cspell.jsonc | 9 +- .github/workflows/cd.yml | 7 +- .github/workflows/ci.yml | 37 ++++---- CHANGELOG.md | 4 +- Cargo.toml | 18 ++-- README.md | 135 ++++++++++++++++------------- src/config.rs | 13 ++- src/consts.rs | 16 ++-- src/lib.rs | 79 +++-------------- src/symmetric/mod.rs | 106 ++++++++++++++++++++++ src/{ => symmetric}/openssl_aes.rs | 8 +- src/{ => symmetric}/pure_aes.rs | 29 ++++--- src/symmetric/xchacha20.rs | 53 +++++++++++ src/types.rs | 2 - src/utils.rs | 105 +++------------------- tests/integration.rs | 9 +- 16 files changed, 349 insertions(+), 281 deletions(-) create mode 100644 src/symmetric/mod.rs rename src/{ => symmetric}/openssl_aes.rs (84%) rename src/{ => symmetric}/pure_aes.rs (67%) create mode 100644 src/symmetric/xchacha20.rs delete mode 100644 src/types.rs diff --git a/.cspell.jsonc b/.cspell.jsonc index 07671ff..9a6662c 100644 --- a/.cspell.jsonc +++ b/.cspell.jsonc @@ -6,9 +6,11 @@ // words - list of words to be always considered correct "words": [ "bindgen", + "chacha", "Codacy", "consts", "Ctarget", + "docsrs", "ecies", "eciespy", "eciesrs", @@ -24,12 +26,15 @@ "ssse", "struct", "symm", - "typenum" + "typenum", + "xchacha" ], // flagWords - list of words to be always considered incorrect // This is useful for offensive words and common spelling errors. // For example "hte" should be "the" - "flagWords": ["hte"], + "flagWords": [ + "hte" + ], "ignorePaths": [ ".git", ".github", diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 41f8544..999054d 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -31,14 +31,17 @@ jobs: ~/.cargo/bin target key: ${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - - name: Install wasm dep - run: cargo install wasm-bindgen-cli || true + - name: Run openssl tests run: cargo test - name: Run pure rust tests run: cargo test --no-default-features --features pure + + - name: Install wasm dep + run: cargo install wasm-bindgen-cli || true - name: Run wasm tests run: cargo test --no-default-features --features pure --target=wasm32-unknown-unknown + - name: Publish cargo package run: cargo login $CARGO_LOGIN_TOKEN && cargo publish env: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ce4e04..8a64bef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] toolchain: [stable, beta, nightly] + feature: [pure, xchacha20, "pure,aes-12bytes-nonce"] steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 @@ -41,30 +42,34 @@ jobs: target key: ${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - - uses: actions-rs/cargo@v1 - with: - command: check - if: matrix.os != 'windows-latest' + # install openssl on Windows + - run: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append + if: matrix.os == 'windows-latest' + - run: New-Item -ItemType "directory" -Path "C:\vcpkg\downloads" -Force + if: matrix.os == 'windows-latest' + - run: Invoke-WebRequest -URI "$env:BASE_URL/$env:FILE_NAME" -OutFile "C:\vcpkg\downloads\$env:FILE_NAME" + env: + BASE_URL: https://github.com/microsoft/vcpkg/files/12073957 + FILE_NAME: nasm-2.16.01-win64.zip + if: matrix.os == 'windows-latest' + - run: vcpkg install openssl:x64-windows-static-md + if: matrix.os == 'windows-latest' - - uses: actions-rs/cargo@v1 - with: - command: fmt - if: matrix.os != 'windows-latest' - - - uses: actions-rs/cargo@v1 - with: - command: clippy - if: matrix.os != 'windows-latest' + - name: Check and run lint + run: cargo check && cargo fmt && cargo clippy - name: Run openssl tests run: cargo test - if: matrix.os != 'windows-latest' + - name: Run openssl 12 bytes nonce tests + run: cargo test --features aes-12bytes-nonce + - name: Run pure rust tests - run: cargo test --no-default-features --features pure + run: cargo test --no-default-features --features ${{ matrix.feature }} + - name: Install wasm dep run: cargo install wasm-bindgen-cli || true - name: Run wasm tests - run: cargo test --no-default-features --features pure --target=wasm32-unknown-unknown + run: cargo test --no-default-features --features ${{ matrix.feature }} --target=wasm32-unknown-unknown - name: Check cargo package run: cargo publish --dry-run diff --git a/CHANGELOG.md b/CHANGELOG.md index 92cfd4f..78234bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # Changelog -## 0.2.1 ~ 0.2.4 +## 0.2.1 ~ 0.2.5 +- Revamp documentation +- Revamp configuration and add xchacha20-poly1305 backend - Add configuration for more compatibility - Revamp error handling - Migrate to edition 2021 diff --git a/Cargo.toml b/Cargo.toml index a84e98a..448a79f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ecies" -version = "0.2.4" +version = "0.2.5" # docs authors = ["Weiliang Li "] description = "Elliptic Curve Integrated Encryption Scheme for secp256k1 in Rust" @@ -21,7 +21,7 @@ repository = "https://github.com/ecies/rs" [dependencies] hkdf = "0.12.3" -libsecp256k1 = "0.7.1" +libsecp256k1 = {version = "0.7.1", default-features = false, features = ["static-context"]} once_cell = "1.18.0" sha2 = "0.10.7" @@ -29,8 +29,11 @@ sha2 = "0.10.7" openssl = {version = "0.10.55", optional = true} # pure rust aes -aes-gcm = {version = "0.10.2", optional = true} -typenum = {version = "1.16.0", optional = true} +aes-gcm = {version = "0.10.2", default-features = false, optional = true} +typenum = {version = "1.16.0", default-features = false, optional = true} + +# chacha20 cipher +chacha20poly1305 = {version = "0.10.1", default-features = false, optional = true} [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = {version = "0.2.10", features = ["js"]} @@ -41,9 +44,10 @@ rand = {version = "0.8.5"} [features] default = ["openssl"] -pure = ["aes-gcm", "typenum"] -# default: 16 bytes -aes_12bytes_nonce = [] + +aes-12bytes-nonce = [] # with feature "openssl" or "pure". default: 16 bytes +pure = ["aes-gcm/aes", "typenum"] +xchacha20 = ["chacha20poly1305/std"] [dev-dependencies] criterion = {version = "0.5.1", default-features = false} diff --git a/README.md b/README.md index 359e0b3..f0f5412 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,44 @@ to speed up AES encryption/decryption. This would be no longer necessary when [` It's also possible to build to the `wasm32-unknown-unknown` target with the pure Rust backend. Check out [this repo](https://github.com/ecies/rs-wasm) for more details. +## Configuration + +You can enable 12 bytes nonce by specify `aes-12bytes-nonce` feature. + +```toml +ecies = {version = "0.2", features = ["aes-12bytes-nonce"]} # it also works for "pure" +``` + +You can also enable a pure Rust [XChaCha20-Poly1305](https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305) backend. + +```toml +ecies = {version = "0.2", default-features = false, features = ["xchacha20"]} +``` + +Other behaviors can be configured by global static variable: + +```rust +pub struct Config { + pub is_ephemeral_key_compressed: bool, + pub is_hkdf_key_compressed: bool +} +``` + +If you set `is_ephemeral_key_compressed: true`, the payload would be like: `33 Bytes + AES` instead of `65 Bytes + AES`. + +If you set `is_hkdf_key_compressed: true`, the hkdf key would be derived from `ephemeral public key (compressed) + shared public key (compressed)` instead of `ephemeral public key (uncompressed) + shared public key (uncompressed)`. + +```rust +use ecies::config::{Config, update_config}; + +update_config(Config { + is_ephemeral_key_compressed: true, + is_hkdf_key_compressed: true +}); +``` + +For compatibility, make sure different applications share the same configuration. + ## Security ### Why AES-GCM-256 and HKDF-SHA256 @@ -66,6 +104,10 @@ AEAD scheme like AES-GCM-256 should be your first option for symmetric ciphers, For key derivation functions on shared points between two asymmetric keys, HKDFs are [proven](https://github.com/ecies/py/issues/82) to be more secure than simple hash functions like SHA256. +### Why XChaCha20-Poly1305 instead of AES-GCM-256 + +XChaCha20-Poly1305 is a competitive alternative to AES-256-GCM because it's fast and constant-time without hardware acceleration (resistent to cache-timing attacks). It also has longer nonce length. + ### Cross-language compatibility All functionalities are mutually checked among [different languages](https://github.com/ecies): Python, Rust, JavaScript and Golang. @@ -74,97 +116,72 @@ All functionalities are mutually checked among [different languages](https://git Following dependencies are audited: -- [aes-gcm](https://research.nccgroup.com/2020/02/26/public-report-rustcrypto-aes-gcm-and-chacha20poly1305-implementation-review/) +- [aes-gcm and chacha20poly1305](https://research.nccgroup.com/2020/02/26/public-report-rustcrypto-aes-gcm-and-chacha20poly1305-implementation-review/) - [OpenSSL](https://ostif.org/the-ostif-and-quarkslab-audit-of-openssl-is-complete/) ## Benchmark -The result shows that the pure Rust backend is around 20% ~ 50% slower compared to OpenSSL on MacBook Pro mid-2015 (2.8 GHz Quad-Core Intel Core i7). +On MacBook Pro Mid 2015 (15-inch, 2.8 GHz Quad-Core Intel Core i7) on July 19, 2023. -### OpenSSL backend +### AES backend (OpenSSL) ```bash $ cargo bench --no-default-features --features openssl -encrypt 100M time: [110.25 ms 115.77 ms 120.22 ms] - change: [-10.123% -3.0504% +4.2342%] (p = 0.44 > 0.05) - No change in performance detected. +encrypt 100M time: [100.21 ms 100.79 ms 101.80 ms] -encrypt 200M time: [435.22 ms 450.50 ms 472.17 ms] - change: [-7.5254% +3.6572% +14.508%] (p = 0.56 > 0.05) - No change in performance detected. -Found 1 outliers among 10 measurements (10.00%) - 1 (10.00%) high mild - -decrypt 100M time: [60.439 ms 66.276 ms 70.959 ms] - change: [+0.1986% +7.7620% +15.995%] (p = 0.08 > 0.05) - No change in performance detected. +encrypt 200M time: [377.84 ms 384.42 ms 390.58 ms] +Found 2 outliers among 10 measurements (20.00%) + 2 (20.00%) high mild -decrypt 200M time: [182.10 ms 185.85 ms 190.63 ms] - change: [-4.8452% +5.2114% +16.370%] (p = 0.40 > 0.05) - No change in performance detected. +decrypt 100M time: [52.430 ms 55.605 ms 60.900 ms] Found 1 outliers among 10 measurements (10.00%) 1 (10.00%) high severe +decrypt 200M time: [157.87 ms 158.98 ms 160.01 ms] +Found 1 outliers among 10 measurements (10.00%) + 1 (10.00%) high mild ``` -### Pure Rust backend +### AES backend (Pure Rust) ```bash $ export RUSTFLAGS="-Ctarget-cpu=sandybridge -Ctarget-feature=+aes,+sse2,+sse4.1,+ssse3" $ cargo bench --no-default-features --features pure -encrypt 100M time: [196.85 ms 201.97 ms 205.67 ms] - change: [-9.8235% -7.9098% -5.9849%] (p = 0.00 < 0.05) - Performance has improved. +encrypt 100M time: [196.63 ms 205.63 ms 222.25 ms] Found 1 outliers among 10 measurements (10.00%) - 1 (10.00%) low severe - -encrypt 200M time: [554.62 ms 585.01 ms 599.71 ms] - change: [-15.036% -11.698% -8.6460%] (p = 0.00 < 0.05) - Performance has improved. - -decrypt 100M time: [131.26 ms 134.39 ms 140.54 ms] - change: [-3.9509% +2.9485% +10.198%] (p = 0.42 > 0.05) - No change in performance detected. + 1 (10.00%) high severe -decrypt 200M time: [288.13 ms 296.64 ms 311.78 ms] - change: [-16.887% -13.038% -8.6679%] (p = 0.00 < 0.05) - Performance has improved. +Benchmarking encrypt 200M: Warming up for 3.0000 s +encrypt 200M time: [587.78 ms 590.71 ms 592.46 ms] Found 1 outliers among 10 measurements (10.00%) 1 (10.00%) high mild -``` -## Configuration - -You can enable 12 bytes nonce by specify `aes_12bytes_nonce` feature. +decrypt 100M time: [144.78 ms 145.54 ms 147.17 ms] +Found 1 outliers among 10 measurements (10.00%) + 1 (10.00%) high mild -```toml -ecies = {version = "0.2", default-features = false, features = ["aes_12bytes_nonce"]} +decrypt 200M time: [363.14 ms 364.48 ms 365.74 ms] ``` -Other behaviors can be configured by global static variable: - -```rs -pub struct Config { - pub is_ephemeral_key_compressed: bool, - pub is_hkdf_key_compressed: bool, - pub symmetric_algorithm: SymmetricAlgorithm, -} -``` +### XChaCha20 backend +```bash +$ cargo bench --no-default-features --features xchacha20 +encrypt 100M time: [149.52 ms 150.06 ms 150.59 ms] +Found 1 outliers among 10 measurements (10.00%) + 1 (10.00%) high mild -If you set `is_ephemeral_key_compressed: true`, the payload would be like: `33 Bytes + AES` instead of `65 Bytes + AES`. +encrypt 200M time: [482.27 ms 484.95 ms 487.45 ms] +Found 3 outliers among 10 measurements (30.00%) + 2 (20.00%) low severe + 1 (10.00%) high severe -If you set `is_hkdf_key_compressed: true`, the hkdf key would be derived from `ephemeral public key (compressed) + shared public key (compressed)` instead of `ephemeral public key (uncompressed) + shared public key (uncompressed)`. +decrypt 100M time: [98.232 ms 100.37 ms 105.65 ms] +Found 1 outliers among 10 measurements (10.00%) + 1 (10.00%) high severe -```rs -update_config(Config { - is_ephemeral_key_compressed: true, - is_hkdf_key_compressed: true, - ..Config::default() -}); +decrypt 200M time: [265.62 ms 268.02 ms 269.85 ms] ``` -For compatibility, make sure different applications share the same configuration. - ## Changelog See [CHANGELOG.md](./CHANGELOG.md). diff --git a/src/config.rs b/src/config.rs index d705978..f10f18b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,17 +4,11 @@ use once_cell::sync::Lazy; use crate::consts::{COMPRESSED_PUBLIC_KEY_SIZE, UNCOMPRESSED_PUBLIC_KEY_SIZE}; -#[derive(Default)] -pub enum SymmetricAlgorithm { - #[default] - Aes256Gcm, -} - +/// ECIES config. Make sure all parties use the same config #[derive(Default)] pub struct Config { pub is_ephemeral_key_compressed: bool, pub is_hkdf_key_compressed: bool, - pub symmetric_algorithm: SymmetricAlgorithm, } /// Global config variable @@ -23,18 +17,22 @@ pub static ECIES_CONFIG: Lazy> = Lazy::new(|| { Mutex::new(config) }); +/// Update global config pub fn update_config(config: Config) { *ECIES_CONFIG.lock().unwrap() = config; } +/// Reset global config to default pub fn reset_config() { update_config(Config::default()) } +/// Get ephemeral key compressed or not pub fn is_ephemeral_key_compressed() -> bool { ECIES_CONFIG.lock().unwrap().is_ephemeral_key_compressed } +/// Get ephemeral key size: compressed(33) or uncompressed(65) pub fn get_ephemeral_key_size() -> usize { if is_ephemeral_key_compressed() { COMPRESSED_PUBLIC_KEY_SIZE @@ -43,6 +41,7 @@ pub fn get_ephemeral_key_size() -> usize { } } +/// Get hkdf key derived from compressed shared point or not pub fn is_hkdf_key_compressed() -> bool { ECIES_CONFIG.lock().unwrap().is_hkdf_key_compressed } diff --git a/src/consts.rs b/src/consts.rs index 936fe3e..38574a8 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -1,16 +1,20 @@ -pub use libsecp256k1::util::{COMPRESSED_PUBLIC_KEY_SIZE, FULL_PUBLIC_KEY_SIZE as UNCOMPRESSED_PUBLIC_KEY_SIZE}; +/// Compressed public key size +pub use libsecp256k1::util::COMPRESSED_PUBLIC_KEY_SIZE; +/// Uncompressed public key size +pub use libsecp256k1::util::FULL_PUBLIC_KEY_SIZE as UNCOMPRESSED_PUBLIC_KEY_SIZE; /// AES nonce length -#[cfg(not(feature = "aes_12bytes_nonce"))] +#[cfg(not(feature = "aes-12bytes-nonce"))] pub const AES_NONCE_LENGTH: usize = 16; -#[cfg(feature = "aes_12bytes_nonce")] +#[cfg(feature = "aes-12bytes-nonce")] pub const AES_NONCE_LENGTH: usize = 12; +/// XChaCha20 nonce length +#[cfg(feature = "xchacha20")] +pub const XCHACHA20_NONCE_LENGTH: usize = 24; + /// AEAD tag length pub const AEAD_TAG_LENGTH: usize = 16; -/// Nonce and tag length -pub const NONCE_TAG_LENGTH: usize = AES_NONCE_LENGTH + AEAD_TAG_LENGTH; - /// Empty bytes array pub const EMPTY_BYTES: [u8; 0] = []; diff --git a/src/lib.rs b/src/lib.rs index 85f58da..9333c26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,74 +1,19 @@ -//! Elliptic Curve Integrated Encryption Scheme for secp256k1 in Rust, based on [pure Rust implementation](https://github.com/paritytech/libsecp256k1) of secp256k1. -//! -//! ECIES functionalities are built upon AES-GCM-256 and HKDF-SHA256. -//! -//! This is the Rust version of [eciespy](https://github.com/ecies/py). -//! -//! This library can be compiled to the WASM target at your option, see [WASM compatibility](#wasm-compatibility). -//! -//! # Quick Start -//! -//! ```rust -//! use ecies::{decrypt, encrypt, utils::generate_keypair}; -//! -//! const MSG: &str = "helloworld"; -//! let (sk, pk) = generate_keypair(); -//! let (sk, pk) = (&sk.serialize(), &pk.serialize()); -//! -//! let msg = MSG.as_bytes(); -//! assert_eq!( -//! msg, -//! decrypt(sk, &encrypt(pk, msg).unwrap()).unwrap().as_slice() -//! ); -//! ``` -//! -//! # Optional pure Rust AES backend -//! -//! You can choose to use OpenSSL implementation or [pure Rust implementation](https://github.com/RustCrypto/AEADs) of AES-256-GCM: -//! -//! ```toml -//! ecies = {version = "0.2", default-features = false, features = ["pure"]} -//! ``` -//! -//! Due to some [performance problem](https://github.com/RustCrypto/AEADs/issues/243), OpenSSL is the default backend. -//! -//! Pure Rust implementation is sometimes useful, such as building on WASM: -//! -//! ```bash -//! cargo build --no-default-features --features pure --target=wasm32-unknown-unknown -//! ``` -//! -//! If you select the pure Rust backend on modern CPUs, consider building with -//! -//! ```bash -//! RUSTFLAGS="-Ctarget-cpu=sandybridge -Ctarget-feature=+aes,+sse2,+sse4.1,+ssse3" -//! ``` -//! -//! to speed up AES encryption/decryption. This would be no longer necessary when [`aes-gcm` supports automatic CPU detection](https://github.com/RustCrypto/AEADs/issues/243#issuecomment-738821935). -//! -//! # WASM compatibility -//! -//! It's also possible to build to the `wasm32-unknown-unknown` target with the pure Rust backend. Check out [this repo](https://github.com/ecies/rs-wasm) for more details. +#![doc = include_str!("../README.md")] -use config::{get_ephemeral_key_size, is_ephemeral_key_compressed}; pub use libsecp256k1::{Error as SecpError, PublicKey, SecretKey}; +/// ECIES configuration +pub mod config; /// Constant variables pub mod consts; -/// Type aliases -pub mod types; -/// Utility functions for ecies +/// Symmetric encryption/decryption +pub mod symmetric; +/// Utility functions pub mod utils; -// ecies configuration -pub mod config; - -#[cfg(feature = "openssl")] -mod openssl_aes; -#[cfg(feature = "pure")] -mod pure_aes; - -use utils::{aes_decrypt, aes_encrypt, decapsulate, encapsulate, generate_keypair}; +use config::{get_ephemeral_key_size, is_ephemeral_key_compressed}; +use symmetric::{sym_decrypt, sym_encrypt}; +use utils::{decapsulate, encapsulate, generate_keypair}; /// Encrypt a message by a public key /// @@ -81,7 +26,7 @@ pub fn encrypt(receiver_pub: &[u8], msg: &[u8]) -> Result, SecpError> { let (ephemeral_sk, ephemeral_pk) = generate_keypair(); let aes_key = encapsulate(&ephemeral_sk, &receiver_pk)?; - let encrypted = aes_encrypt(&aes_key, msg).ok_or(SecpError::InvalidMessage)?; + let encrypted = sym_encrypt(&aes_key, msg).ok_or(SecpError::InvalidMessage)?; let key_size = get_ephemeral_key_size(); let mut cipher_text = Vec::with_capacity(key_size + encrypted.len()); @@ -116,18 +61,16 @@ pub fn decrypt(receiver_sec: &[u8], msg: &[u8]) -> Result, SecpError> { let aes_key = decapsulate(&ephemeral_pk, &receiver_sk)?; - aes_decrypt(&aes_key, encrypted).ok_or(SecpError::InvalidMessage) + sym_decrypt(&aes_key, encrypted).ok_or(SecpError::InvalidMessage) } #[cfg(test)] mod tests { - use super::*; use utils::generate_keypair; const MSG: &str = "helloworld"; - const BIG_MSG_SIZE: usize = 2 * 1024 * 1024; // 2 MB const BIG_MSG: [u8; BIG_MSG_SIZE] = [1u8; BIG_MSG_SIZE]; diff --git a/src/symmetric/mod.rs b/src/symmetric/mod.rs new file mode 100644 index 0000000..a5e0258 --- /dev/null +++ b/src/symmetric/mod.rs @@ -0,0 +1,106 @@ +#[cfg(feature = "openssl")] +mod openssl_aes; +#[cfg(feature = "pure")] +mod pure_aes; +#[cfg(feature = "xchacha20")] +mod xchacha20; + +#[cfg(feature = "openssl")] +use openssl_aes::{decrypt, encrypt}; +#[cfg(feature = "pure")] +use pure_aes::{decrypt, encrypt}; +#[cfg(feature = "xchacha20")] +use xchacha20::{decrypt, encrypt}; + +/// Symmetric encryption wrapper. Openssl AES-256-GCM, pure Rust AES-256-GCM, or XChaCha20-Poly1305 +pub fn sym_encrypt(key: &[u8], msg: &[u8]) -> Option> { + encrypt(key, msg) +} + +/// Symmetric decryption wrapper +pub fn sym_decrypt(key: &[u8], encrypted_msg: &[u8]) -> Option> { + decrypt(key, encrypted_msg) +} + +#[cfg(test)] +pub(crate) mod tests { + use hex::decode; // dev dep + use rand::{thread_rng, Rng}; + + use super::*; + + /// Remove 0x prefix of a hex string + pub fn remove0x(hex: &str) -> &str { + if hex.starts_with("0x") || hex.starts_with("0X") { + return &hex[2..]; + } + hex + } + + /// Convert hex string to u8 vector + pub fn decode_hex(hex: &str) -> Vec { + decode(remove0x(hex)).unwrap() + } + + #[test] + fn test_attempt_to_decrypt_invalid_message() { + assert!(decrypt(&[], &[]).is_none()); + assert!(decrypt(&[], &[0; 16]).is_none()); + } + + #[test] + fn test_random_key() { + let text = b"this is a text"; + let mut key = [0u8; 32]; + thread_rng().fill(&mut key); + + assert_eq!( + text, + decrypt(&key, encrypt(&key, text).unwrap().as_slice()) + .unwrap() + .as_slice() + ); + + let utf8_text = "😀😀😀😀".as_bytes(); + assert_eq!( + utf8_text, + decrypt(&key, encrypt(&key, utf8_text).unwrap().as_slice()) + .unwrap() + .as_slice() + ); + } + + #[test] + #[cfg(all(not(feature = "aes-12bytes-nonce"), not(feature = "xchacha20")))] + fn test_aes_known_key() { + let text = b"helloworld"; + let key = decode_hex("0000000000000000000000000000000000000000000000000000000000000000"); + let iv = decode_hex("f3e1ba810d2c8900b11312b7c725565f"); + let tag = decode_hex("ec3b71e17c11dbe31484da9450edcf6c"); + let encrypted = decode_hex("02d2ffed93b856f148b9"); + + let mut cipher_text = Vec::new(); + cipher_text.extend(iv); + cipher_text.extend(tag); + cipher_text.extend(encrypted); + + assert_eq!(text, decrypt(&key, &cipher_text).unwrap().as_slice()); + } + + #[test] + #[cfg(feature = "xchacha20")] + fn test_xchacha20_known_key() { + let text = b"helloworld"; + let key = decode_hex("27bd6ec46292a3b421cdaf8a3f0ca759cbc67bcbe7c5855aa0d1e0700fd0e828"); + let nonce = decode_hex("fbd5dd10431af533c403d6f4fa629931e5f31872d2f7e7b6"); + let tag = decode_hex("5b5ccc27324af03b7ca92dd067ad6eb5"); + let encrypted = decode_hex("aa0664f3c00a09d098bf"); + + let mut cipher_text = Vec::with_capacity(encrypted.len() + 24); + cipher_text.extend(nonce); + cipher_text.extend(tag); + cipher_text.extend(encrypted); + + assert_eq!(text, sym_decrypt(&key, &cipher_text).unwrap().as_slice()); + } +} diff --git a/src/openssl_aes.rs b/src/symmetric/openssl_aes.rs similarity index 84% rename from src/openssl_aes.rs rename to src/symmetric/openssl_aes.rs index fe474ca..6e8fa1f 100644 --- a/src/openssl_aes.rs +++ b/src/symmetric/openssl_aes.rs @@ -1,10 +1,12 @@ use openssl::symm::{decrypt_aead, encrypt_aead, Cipher}; use rand::{thread_rng, Rng}; -use crate::consts::{AEAD_TAG_LENGTH, AES_NONCE_LENGTH, EMPTY_BYTES, NONCE_TAG_LENGTH}; +use crate::consts::{AEAD_TAG_LENGTH, AES_NONCE_LENGTH, EMPTY_BYTES}; + +const NONCE_TAG_LENGTH: usize = AES_NONCE_LENGTH + AEAD_TAG_LENGTH; /// AES-256-GCM encryption wrapper -pub fn aes_encrypt(key: &[u8], msg: &[u8]) -> Option> { +pub fn encrypt(key: &[u8], msg: &[u8]) -> Option> { let cipher = Cipher::aes_256_gcm(); let mut iv = [0u8; AES_NONCE_LENGTH]; @@ -25,7 +27,7 @@ pub fn aes_encrypt(key: &[u8], msg: &[u8]) -> Option> { } /// AES-256-GCM decryption wrapper -pub fn aes_decrypt(key: &[u8], encrypted_msg: &[u8]) -> Option> { +pub fn decrypt(key: &[u8], encrypted_msg: &[u8]) -> Option> { if encrypted_msg.len() < NONCE_TAG_LENGTH { return None; } diff --git a/src/pure_aes.rs b/src/symmetric/pure_aes.rs similarity index 67% rename from src/pure_aes.rs rename to src/symmetric/pure_aes.rs index 8c76394..9457dce 100644 --- a/src/pure_aes.rs +++ b/src/symmetric/pure_aes.rs @@ -1,27 +1,29 @@ -use aes_gcm::aead::{generic_array::GenericArray, AeadInPlace}; -use aes_gcm::{aes::Aes256, AesGcm, KeyInit}; +use aes_gcm::{ + aead::{generic_array::GenericArray, AeadInPlace}, + aes::Aes256, + AesGcm, KeyInit, +}; use rand::{thread_rng, Rng}; #[allow(unused_imports)] use typenum::consts::{U12, U16}; -use crate::consts::{AES_NONCE_LENGTH, EMPTY_BYTES, NONCE_TAG_LENGTH}; +use crate::consts::{AEAD_TAG_LENGTH, AES_NONCE_LENGTH, EMPTY_BYTES}; -/// AES-256-GCM with 16 bytes Nonce/IV -#[cfg(not(feature = "aes_12bytes_nonce"))] -pub type Aes256Gcm = AesGcm; +#[cfg(not(feature = "aes-12bytes-nonce"))] +type Aes256Gcm = AesGcm; -/// AES-256-GCM with 12 bytes Nonce/IV -#[cfg(feature = "aes_12bytes_nonce")] -pub type Aes256Gcm = AesGcm; +#[cfg(feature = "aes-12bytes-nonce")] +type Aes256Gcm = AesGcm; + +const NONCE_TAG_LENGTH: usize = AES_NONCE_LENGTH + AEAD_TAG_LENGTH; /// AES-256-GCM encryption wrapper -pub fn aes_encrypt(key: &[u8], msg: &[u8]) -> Option> { - let key: &GenericArray = GenericArray::from_slice(key); +pub fn encrypt(key: &[u8], msg: &[u8]) -> Option> { + let key = GenericArray::from_slice(key); let aead = Aes256Gcm::new(key); let mut iv = [0u8; AES_NONCE_LENGTH]; thread_rng().fill(&mut iv); - let nonce = GenericArray::from_slice(&iv); let mut out = Vec::with_capacity(msg.len()); @@ -39,11 +41,10 @@ pub fn aes_encrypt(key: &[u8], msg: &[u8]) -> Option> { } /// AES-256-GCM decryption wrapper -pub fn aes_decrypt(key: &[u8], encrypted_msg: &[u8]) -> Option> { +pub fn decrypt(key: &[u8], encrypted_msg: &[u8]) -> Option> { if encrypted_msg.len() < NONCE_TAG_LENGTH { return None; } - let key = GenericArray::from_slice(key); let aead = Aes256Gcm::new(key); diff --git a/src/symmetric/xchacha20.rs b/src/symmetric/xchacha20.rs new file mode 100644 index 0000000..850ebcf --- /dev/null +++ b/src/symmetric/xchacha20.rs @@ -0,0 +1,53 @@ +use chacha20poly1305::{ + aead::{generic_array::GenericArray, AeadInPlace}, + KeyInit, XChaCha20Poly1305, +}; +use rand::{thread_rng, Rng}; + +use crate::consts::{AEAD_TAG_LENGTH, EMPTY_BYTES, XCHACHA20_NONCE_LENGTH}; + +const NONCE_TAG_LENGTH: usize = XCHACHA20_NONCE_LENGTH + AEAD_TAG_LENGTH; + +/// XChaCha20-Poly1305 encryption wrapper +pub fn encrypt(key: &[u8], msg: &[u8]) -> Option> { + let key = GenericArray::from_slice(key); + let aead = XChaCha20Poly1305::new(key); + + let mut iv = [0u8; XCHACHA20_NONCE_LENGTH]; + thread_rng().fill(&mut iv); + let nonce = GenericArray::from_slice(&iv); + + let mut out = Vec::with_capacity(msg.len()); + out.extend(msg); + + if let Ok(tag) = aead.encrypt_in_place_detached(nonce, &EMPTY_BYTES, &mut out) { + let mut output = Vec::with_capacity(NONCE_TAG_LENGTH + msg.len()); + output.extend(nonce); + output.extend(tag); + output.extend(out); + Some(output) + } else { + None + } +} + +/// XChaCha20-Poly1305 decryption wrapper +pub fn decrypt(key: &[u8], encrypted_msg: &[u8]) -> Option> { + if encrypted_msg.len() < NONCE_TAG_LENGTH { + return None; + } + let key = GenericArray::from_slice(key); + let aead = XChaCha20Poly1305::new(key); + + let iv = GenericArray::from_slice(&encrypted_msg[..XCHACHA20_NONCE_LENGTH]); + let tag = GenericArray::from_slice(&encrypted_msg[XCHACHA20_NONCE_LENGTH..NONCE_TAG_LENGTH]); + + let mut out = Vec::with_capacity(encrypted_msg.len() - NONCE_TAG_LENGTH); + out.extend(&encrypted_msg[NONCE_TAG_LENGTH..]); + + if let Ok(_) = aead.decrypt_in_place_detached(iv, &EMPTY_BYTES, &mut out, tag) { + Some(out) + } else { + None + } +} diff --git a/src/types.rs b/src/types.rs deleted file mode 100644 index d5abe12..0000000 --- a/src/types.rs +++ /dev/null @@ -1,2 +0,0 @@ -/// Type alias for `[u8; 32]`, which is a 256-bit key -pub type AesKey = [u8; 32]; diff --git a/src/utils.rs b/src/utils.rs index 981bdae..8ff186b 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -5,13 +5,9 @@ use sha2::Sha256; use crate::config::{get_ephemeral_key_size, is_hkdf_key_compressed}; use crate::consts::EMPTY_BYTES; -use crate::types::AesKey; -#[cfg(feature = "pure")] -pub use crate::pure_aes::{aes_decrypt, aes_encrypt}; - -#[cfg(feature = "openssl")] -pub use crate::openssl_aes::{aes_decrypt, aes_encrypt}; +/// Shared secret derived from key exchange by hkdf +pub type SharedSecret = [u8; 32]; /// Generate a `(SecretKey, PublicKey)` pair pub fn generate_keypair() -> (SecretKey, PublicKey) { @@ -20,7 +16,7 @@ pub fn generate_keypair() -> (SecretKey, PublicKey) { } /// Calculate a shared AES key of our secret key and peer's public key by hkdf -pub fn encapsulate(sk: &SecretKey, peer_pk: &PublicKey) -> Result { +pub fn encapsulate(sk: &SecretKey, peer_pk: &PublicKey) -> Result { let mut shared_point = *peer_pk; shared_point.tweak_mul_assign(sk)?; @@ -29,7 +25,7 @@ pub fn encapsulate(sk: &SecretKey, peer_pk: &PublicKey) -> Result Result { +pub fn decapsulate(pk: &PublicKey, peer_sk: &SecretKey) -> Result { let mut shared_point = *pk; shared_point.tweak_mul_assign(peer_sk)?; @@ -37,7 +33,7 @@ pub fn decapsulate(pk: &PublicKey, peer_sk: &SecretKey) -> Result Result { +fn derive_key(pk: &PublicKey, shared_point: &PublicKey) -> Result { let key_size = get_ephemeral_key_size(); let mut master = Vec::with_capacity(key_size * 2); @@ -51,7 +47,7 @@ fn derive_key(pk: &PublicKey, shared_point: &PublicKey) -> Result Result { +fn hkdf_sha256(master: &[u8]) -> Result { let h = Hkdf::::new(None, master); let mut out = [0u8; 32]; h.expand(&EMPTY_BYTES, &mut out) @@ -60,36 +56,11 @@ fn hkdf_sha256(master: &[u8]) -> Result { } #[cfg(test)] -pub(crate) mod tests { +mod tests { use libsecp256k1::Error; - use rand::{thread_rng, Rng}; - - // dev dep - use hex::decode; use super::*; - use crate::consts::EMPTY_BYTES; - - /// Remove 0x prefix of a hex string - pub fn remove0x(hex: &str) -> &str { - if hex.starts_with("0x") || hex.starts_with("0X") { - return &hex[2..]; - } - hex - } - - /// Convert hex string to u8 vector - pub fn decode_hex(hex: &str) -> Vec { - decode(remove0x(hex)).unwrap() - } - - #[test] - fn test_remove_0x_decode_hex() { - assert_eq!(remove0x("0x0011"), "0011"); - assert_eq!(remove0x("0X0011"), "0011"); - assert_eq!(remove0x("0011"), "0011"); - assert_eq!(decode_hex("0x0011"), [0u8, 17u8]); - } + use crate::symmetric::tests::decode_hex; #[test] fn test_generate_keypair() { @@ -99,51 +70,6 @@ pub(crate) mod tests { assert_ne!(pk1, pk2); } - #[test] - fn test_attempt_to_decrypt_invalid_message() { - assert!(aes_decrypt(&[], &[]).is_none()); - assert!(aes_decrypt(&[], &[0; 16]).is_none()); - } - - #[test] - fn test_aes_random_key() { - let text = b"this is a text"; - let mut key = [0u8; 32]; - thread_rng().fill(&mut key); - - assert_eq!( - text, - aes_decrypt(&key, aes_encrypt(&key, text).unwrap().as_slice()) - .unwrap() - .as_slice() - ); - - let utf8_text = "😀😀😀😀".as_bytes(); - assert_eq!( - utf8_text, - aes_decrypt(&key, aes_encrypt(&key, utf8_text).unwrap().as_slice()) - .unwrap() - .as_slice() - ); - } - - #[test] - #[cfg(not(feature = "aes_12bytes_nonce"))] - fn test_aes_known_key() { - let text = b"helloworld"; - let key = decode_hex("0000000000000000000000000000000000000000000000000000000000000000"); - let iv = decode_hex("f3e1ba810d2c8900b11312b7c725565f"); - let tag = decode_hex("ec3b71e17c11dbe31484da9450edcf6c"); - let encrypted = decode_hex("02d2ffed93b856f148b9"); - - let mut cipher_text = Vec::new(); - cipher_text.extend(iv); - cipher_text.extend(tag); - cipher_text.extend(encrypted); - - assert_eq!(text, aes_decrypt(&key, &cipher_text).unwrap().as_slice()); - } - #[test] fn test_valid_secret() { // 0 < private key < group order int is valid @@ -159,21 +85,18 @@ pub(crate) mod tests { Error::InvalidSecretKey ); } - #[test] - fn test_hkdf() { + fn test_known_hkdf_vector() { let text = b"secret"; - let h = Hkdf::::new(None, text); - let mut out = [0u8; 32]; - let r = h.expand(&EMPTY_BYTES, &mut out); - - assert!(r.is_ok()); assert_eq!( - out.to_vec(), + hkdf_sha256(text).unwrap().to_vec(), decode_hex("2f34e5ff91ec85d53ca9b543683174d0cf550b60d5f52b24c97b386cfcf6cbbf") ); + } + #[test] + fn test_known_shared_secret() { let mut two = [0u8; 32]; let mut three = [0u8; 32]; two[31] = 2u8; @@ -186,7 +109,7 @@ pub(crate) mod tests { assert_eq!(encapsulate(&sk2, &pk3), decapsulate(&pk2, &sk3)); assert_eq!( - encapsulate(&sk2, &pk3).map(|v| v.to_vec()).unwrap(), + encapsulate(&sk2, &pk3).unwrap().to_vec(), decode_hex("6f982d63e8590c9d9b5b4c1959ff80315d772edd8f60287c9361d548d5200f82") ); } diff --git a/tests/integration.rs b/tests/integration.rs index dc51925..c051323 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -28,14 +28,13 @@ fn can_change_behavior_with_config() { assert_eq!(encapsulate(&sk2, &pk3), decapsulate(&pk2, &sk3)); assert_eq!( - encapsulate(&sk2, &pk3).map(|v| v.to_vec()).unwrap(), + encapsulate(&sk2, &pk3).unwrap().to_vec(), decode("b192b226edb3f02da11ef9c6ce4afe1c7e40be304e05ae3b988f4834b1cb6c69").unwrap() ); update_config(Config { is_ephemeral_key_compressed: true, is_hkdf_key_compressed: true, - ..Config::default() }); let (sk, pk) = generate_keypair(); @@ -50,7 +49,11 @@ fn can_change_behavior_with_config() { } #[test] -#[cfg(all(not(target_arch = "wasm32"), not(feature = "aes_12bytes_nonce")))] +#[cfg(all( + not(target_arch = "wasm32"), + not(feature = "aes-12bytes-nonce"), + not(feature = "xchacha20") +))] fn is_compatible_with_python() { use futures_util::FutureExt; use hex::encode;