From f845aaed5a4e6ff9830bb694036e12f996a4c307 Mon Sep 17 00:00:00 2001 From: Weiliang Li Date: Fri, 18 Aug 2023 17:59:14 +0900 Subject: [PATCH] Refactor pure Rust aes and xchacha20 --- .cargo/config.toml | 3 + .github/workflows/cd.yml | 32 +++------ .github/workflows/ci.yml | 68 +++++++++--------- .gitignore | 3 + CHANGELOG.md | 2 +- Cargo.toml | 4 +- README.md | 20 +++--- src/config.rs | 66 ++++++++++++++++++ src/consts.rs | 17 ++--- src/lib.rs | 77 +++++++++----------- src/symmetric/aead.rs | 71 +++++++++++++++++++ src/symmetric/mod.rs | 131 +++++++++++++++++++---------------- src/symmetric/openssl_aes.rs | 33 ++++----- src/symmetric/pure_aes.rs | 63 ----------------- src/symmetric/xchacha20.rs | 54 --------------- src/utils.rs | 102 +++++++++++++++++---------- tests/integration.rs | 78 ++------------------- 17 files changed, 399 insertions(+), 425 deletions(-) create mode 100644 src/symmetric/aead.rs delete mode 100644 src/symmetric/pure_aes.rs delete mode 100644 src/symmetric/xchacha20.rs diff --git a/.cargo/config.toml b/.cargo/config.toml index 4ec2f3b..4f136a6 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,2 +1,5 @@ [target.wasm32-unknown-unknown] runner = 'wasm-bindgen-test-runner' + +[env] +RUST_TEST_THREADS = "1" # restirct concurrency diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 999054d..936aeb9 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -12,35 +12,19 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: 20 - - uses: actions-rs/toolchain@v1 + + - uses: dtolnay/rust-toolchain@master with: - toolchain: stable - override: true + toolchain: ${{ matrix.toolchain }} target: wasm32-unknown-unknown - - uses: actions-rs/cargo@v1 - with: - command: generate-lockfile - - uses: actions/cache@v3 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - ~/.cargo/bin - target - key: ${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} - - - 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 + + - run: cargo generate-lockfile + + - uses: Swatinem/rust-cache@v2 - name: Publish cargo package run: cargo login $CARGO_LOGIN_TOKEN && cargo publish diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d773885..bf09f33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,30 +17,24 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] toolchain: [stable, beta, nightly] - feature: [openssl, pure, xchacha20] steps: - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: 20 - - uses: actions-rs/toolchain@v1 + + - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.toolchain }} - override: true target: wasm32-unknown-unknown - components: rustfmt, clippy - # cargo - - uses: actions-rs/cargo@v1 - with: - command: generate-lockfile - - uses: actions/cache@v3 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - ~/.cargo/bin - target - key: ${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + components: rustfmt, clippy, llvm-tools-preview + + - uses: taiki-e/install-action@cargo-llvm-cov + + - run: cargo generate-lockfile + + - uses: Swatinem/rust-cache@v2 # install openssl on Windows - run: echo "VCPKG_ROOT=$env:VCPKG_INSTALLATION_ROOT" | Out-File -FilePath $env:GITHUB_ENV -Append @@ -57,25 +51,37 @@ jobs: - name: Check and run lint run: cargo check && cargo fmt && cargo clippy + - name: Check doc + run: cargo doc --no-deps - - name: Run tests - run: cargo test --no-default-features --features ${{ matrix.feature }} - - name: Run tests with std - run: cargo test --no-default-features --features ${{ matrix.feature }},std + # OpenSSL AES + - run: cargo test --no-default-features --features openssl + - run: cargo test --no-default-features --features openssl,std + - run: cargo test --no-default-features --features openssl,aes-12bytes-nonce + - run: cargo test --no-default-features --features openssl,std,aes-12bytes-nonce - - name: Run tests with 12 bytes nonce - run: cargo test --no-default-features --features ${{ matrix.feature }},aes-12bytes-nonce - if: matrix.feature != 'xchacha20' + # Pure Rust AES + - run: cargo test --no-default-features --features pure + - run: cargo test --no-default-features --features pure,std + - run: cargo test --no-default-features --features pure,aes-12bytes-nonce + - run: cargo test --no-default-features --features pure,std,aes-12bytes-nonce - - name: Run tests with std and 12 bytes nonce - run: cargo test --no-default-features --features ${{ matrix.feature }},std,aes-12bytes-nonce - if: matrix.feature != 'xchacha20' + # XChaCha20 + - run: cargo test --no-default-features --features xchacha20 + - run: cargo test --no-default-features --features xchacha20,std - - name: Install wasm dep - run: cargo install wasm-bindgen-cli || true - - name: Run tests on wasm target - run: cargo test --no-default-features --features ${{ matrix.feature }} --target=wasm32-unknown-unknown - if: matrix.feature != 'openssl' + # Pure Rust AES and XChaCha20 on WASM target + - run: cargo install wasm-bindgen-cli || true + - run: cargo test --no-default-features --features pure --target=wasm32-unknown-unknown + - run: cargo test --no-default-features --features pure,std --target=wasm32-unknown-unknown + - run: cargo test --no-default-features --features xchacha20 --target=wasm32-unknown-unknown + - run: cargo test --no-default-features --features xchacha20,std --target=wasm32-unknown-unknown + + # Coverage + - run: cargo llvm-cov --no-default-features --features pure --lcov --output-path .lcov.info + - uses: codecov/codecov-action@v3 + with: + files: .lcov.info - name: Check cargo package run: cargo publish --dry-run diff --git a/.gitignore b/.gitignore index 088ba6b..b64240c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + +# llvm coverage files +.lcov.info diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c531d..9530a13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Support `no_std` - Revamp documentation -- Revamp configuration and add xchacha20-poly1305 backend +- 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 15a3755..a279603 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ libsecp256k1 = {version = "0.7.1", default-features = false, features = ["static sha2 = {version = "0.10.7", default-features = false} # openssl aes -openssl = {version = "0.10.55", default-features = false, optional = true} +openssl = {version = "0.10.56", default-features = false, optional = true} # pure rust aes aes-gcm = {version = "0.10.2", default-features = false, optional = true} @@ -70,7 +70,7 @@ wasm-bindgen-test = "0.3.37" [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] futures-util = "0.3.28" reqwest = "0.11.18" -tokio = {version = "1.29.1", features = ["rt-multi-thread"]} +tokio = {version = "1.32.0", features = ["rt-multi-thread"]} [[bench]] harness = false diff --git a/README.md b/README.md index f6956d6..6818038 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ [![Codacy Badge](https://api.codacy.com/project/badge/Grade/1c6d6ed949dd4836ab97421039e8be75)](https://app.codacy.com/gh/ecies/rs/dashboard) [![License](https://img.shields.io/github/license/ecies/rs.svg)](https://github.com/ecies/rs) [![CI](https://img.shields.io/github/actions/workflow/status/ecies/rs/ci.yml)](https://github.com/ecies/rs/actions) +[![Codecov](https://img.shields.io/codecov/c/github/ecies/rs.svg)](https://codecov.io/gh/ecies/rs) [![Crates](https://img.shields.io/crates/v/ecies)](https://crates.io/crates/ecies) [![Doc](https://docs.rs/ecies/badge.svg)](https://docs.rs/ecies/latest/ecies/) 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. +ECIES functionalities are built upon AES-256-GCM and HKDF-SHA256. This is the Rust version of [eciespy](https://github.com/ecies/py). @@ -25,7 +26,7 @@ ecies = {version = "0.2", features = ["std"]} ```rust use ecies::{decrypt, encrypt, utils::generate_keypair}; -const MSG: &str = "helloworld"; +const MSG: &str = "helloworld🌍"; let (sk, pk) = generate_keypair(); let (sk, pk) = (&sk.serialize(), &pk.serialize()); @@ -66,10 +67,10 @@ It's also possible to build to the `wasm32-unknown-unknown` target with the pure ## Configuration -You can enable 12 bytes nonce by specify `aes-12bytes-nonce` feature. +You can enable 12 bytes nonce by `aes-12bytes-nonce` feature on OpenSSL or pure Rust AES backend. ```toml -ecies = {version = "0.2", features = ["aes-12bytes-nonce"]} # it also works for "pure" +ecies = {version = "0.2", features = ["aes-12bytes-nonce"]} # it also works with "pure" ``` You can also enable a pure Rust [XChaCha20-Poly1305](https://github.com/RustCrypto/AEADs/tree/master/chacha20poly1305) backend. @@ -100,19 +101,19 @@ update_config(Config { }); ``` -For compatibility, make sure different applications share the same configuration. +For compatibility, make sure different applications share the same configuration. Normally configuration is only updated once on initialization, if not, beware of race condition. ## Security -### Why AES-GCM-256 and HKDF-SHA256 +### Why AES-256-GCM and HKDF-SHA256 -AEAD scheme like AES-GCM-256 should be your first option for symmetric ciphers, with unique IVs in each encryption. +AEAD scheme like AES-256-GCM should be your first option for symmetric ciphers, with unique IVs in each encryption. 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 +### Why XChaCha20-Poly1305 instead of AES-256-GCM -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. +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 to alleviate the risk of birthday attacks when nonces are generated randomly. ### Cross-language compatibility @@ -170,6 +171,7 @@ decrypt 200M time: [363.14 ms 364.48 ms 365.74 ms] ``` ### XChaCha20 backend + ```bash $ cargo bench --no-default-features --features xchacha20 encrypt 100M time: [149.52 ms 150.06 ms 150.59 ms] diff --git a/src/config.rs b/src/config.rs index 99949bd..9862ef6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,3 +44,69 @@ pub fn get_ephemeral_key_size() -> usize { pub fn is_hkdf_key_compressed() -> bool { ECIES_CONFIG.read().is_hkdf_key_compressed } + +#[cfg(test)] +mod tests { + use super::{reset_config, update_config, Config}; + use libsecp256k1::PublicKey; + + use crate::{ + decrypt, encrypt, + utils::{ + encapsulate, generate_keypair, + tests::{decode_hex, get_sk2_sk3}, + }, + }; + + const MSG: &str = "helloworld🌍"; + + #[test] + pub(super) fn test_known_hkdf_config() { + let (sk2, sk3) = get_sk2_sk3(); + let pk3 = PublicKey::from_secret_key(&sk3); + + update_config(Config { + is_hkdf_key_compressed: true, + ..Config::default() + }); + + let encapsulated = encapsulate(&sk2, &pk3).unwrap(); + + assert_eq!( + encapsulated.to_vec(), + decode_hex("b192b226edb3f02da11ef9c6ce4afe1c7e40be304e05ae3b988f4834b1cb6c69") + ); + + reset_config(); + } + + #[test] + pub(super) fn test_ephemeral_key_config() { + let (sk, pk) = generate_keypair(); + let (sk, pk) = (&sk.serialize(), &pk.serialize_compressed()); + let encrypted_1 = encrypt(pk, MSG.as_bytes()).unwrap(); + assert_eq!(MSG.as_bytes(), &decrypt(sk, &encrypted_1).unwrap()); + + update_config(Config { + is_ephemeral_key_compressed: true, + ..Config::default() + }); + + let encrypted_2 = encrypt(pk, MSG.as_bytes()).unwrap(); + assert_eq!(encrypted_1.len() - encrypted_2.len(), 32); + assert_eq!(MSG.as_bytes(), &decrypt(sk, &encrypted_2).unwrap()); + + reset_config(); + } +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use wasm_bindgen_test::*; + + #[wasm_bindgen_test] + fn test_wasm() { + super::tests::test_ephemeral_key_config(); + super::tests::test_known_hkdf_config(); + } +} diff --git a/src/consts.rs b/src/consts.rs index 38574a8..8ac8272 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -3,18 +3,19 @@ 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"))] -pub const AES_NONCE_LENGTH: usize = 16; -#[cfg(feature = "aes-12bytes-nonce")] -pub const AES_NONCE_LENGTH: usize = 12; - -/// XChaCha20 nonce length +/// Nonce length. AES (12/16 bytes) or XChaCha20 (24 bytes) +#[cfg(all(not(feature = "aes-12bytes-nonce"), not(feature = "xchacha20")))] +pub const NONCE_LENGTH: usize = 16; +#[cfg(all(feature = "aes-12bytes-nonce", not(feature = "xchacha20")))] +pub const NONCE_LENGTH: usize = 12; #[cfg(feature = "xchacha20")] -pub const XCHACHA20_NONCE_LENGTH: usize = 24; +pub const NONCE_LENGTH: usize = 24; /// AEAD tag length pub const AEAD_TAG_LENGTH: usize = 16; +/// Nonce + tag length +pub const NONCE_TAG_LENGTH: usize = 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 85f4ec1..c67f841 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,13 +39,15 @@ pub fn encrypt(receiver_pub: &[u8], msg: &[u8]) -> Result, SecpError> { let aes_key = encapsulate(&ephemeral_sk, &receiver_pk)?; let encrypted = sym_encrypt(&aes_key, msg).ok_or(SecpError::InvalidMessage)?; + let is_compressed = is_ephemeral_key_compressed(); let key_size = get_ephemeral_key_size(); + let mut cipher_text = Vec::with_capacity(key_size + encrypted.len()); - if is_ephemeral_key_compressed() { - cipher_text.extend(ephemeral_pk.serialize_compressed().iter()); + if is_compressed { + cipher_text.extend(&ephemeral_pk.serialize_compressed()); } else { - cipher_text.extend(ephemeral_pk.serialize().iter()); + cipher_text.extend(&ephemeral_pk.serialize()); } cipher_text.extend(encrypted); @@ -77,87 +79,76 @@ pub fn decrypt(receiver_sec: &[u8], msg: &[u8]) -> Result, SecpError> { #[cfg(test)] mod tests { - use super::*; - - use utils::generate_keypair; + use super::{decrypt, encrypt, generate_keypair, SecpError}; - const MSG: &str = "helloworld"; + 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]; - pub(super) fn test_enc_dec(sk: &[u8], pk: &[u8]) { + fn test_enc_dec(sk: &[u8], pk: &[u8]) { let msg = MSG.as_bytes(); - assert_eq!(msg, decrypt(sk, &encrypt(pk, msg).unwrap()).unwrap().as_slice()); - } - - pub(super) fn test_enc_dec_big(sk: &[u8], pk: &[u8]) { + assert_eq!(msg.to_vec(), decrypt(sk, &encrypt(pk, msg).unwrap()).unwrap()); let msg = &BIG_MSG; assert_eq!(msg.to_vec(), decrypt(sk, &encrypt(pk, msg).unwrap()).unwrap()); } #[test] - fn attempts_to_decrypt_with_another_key() { - let (_, pk1) = generate_keypair(); - - let (sk2, _) = generate_keypair(); + pub(super) fn attempts_to_encrypt_with_invalid_key() { + assert_eq!(encrypt(&[0u8; 33], MSG.as_bytes()), Err(SecpError::InvalidPublicKey)); + } - assert_eq!( - decrypt( - &sk2.serialize(), - encrypt(&pk1.serialize_compressed(), b"text").unwrap().as_slice() - ), - Err(SecpError::InvalidMessage) - ); + #[test] + pub(super) fn attempts_to_decrypt_with_invalid_key() { + assert_eq!(decrypt(&[0u8; 32], &[]), Err(SecpError::InvalidSecretKey)); } #[test] - fn attempts_to_decrypt_incorrect_message() { + pub(super) fn attempts_to_decrypt_incorrect_message() { let (sk, _) = generate_keypair(); assert_eq!(decrypt(&sk.serialize(), &[]), Err(SecpError::InvalidMessage)); - assert_eq!(decrypt(&sk.serialize(), &[0u8; 65]), Err(SecpError::InvalidPublicKey)); } #[test] - fn attempts_to_encrypt_with_invalid_key() { - assert_eq!(encrypt(&[0u8; 33], b"text"), Err(SecpError::InvalidPublicKey)); + pub(super) fn attempts_to_decrypt_with_another_key() { + let (_, pk1) = generate_keypair(); + let (sk2, _) = generate_keypair(); + + let encrypted = encrypt(&pk1.serialize_compressed(), MSG.as_bytes()).unwrap(); + assert_eq!(decrypt(&sk2.serialize(), &encrypted), Err(SecpError::InvalidMessage)); } #[test] - fn test_compressed_public() { + pub(super) fn test_compressed_public() { let (sk, pk) = generate_keypair(); let (sk, pk) = (&sk.serialize(), &pk.serialize_compressed()); test_enc_dec(sk, pk); } #[test] - fn test_uncompressed_public() { + pub(super) fn test_uncompressed_public() { let (sk, pk) = generate_keypair(); let (sk, pk) = (&sk.serialize(), &pk.serialize()); test_enc_dec(sk, pk); } - - #[test] - fn test_compressed_public_big_msg() { - let (sk, pk) = generate_keypair(); - let (sk, pk) = (&sk.serialize(), &pk.serialize_compressed()); - test_enc_dec_big(sk, pk); - } } #[cfg(all(test, target_arch = "wasm32"))] mod wasm_tests { - use super::generate_keypair; - use super::tests::{test_enc_dec, test_enc_dec_big}; - use wasm_bindgen_test::*; #[wasm_bindgen_test] fn test_wasm() { - let (sk, pk) = generate_keypair(); - let (sk, pk) = (&sk.serialize(), &pk.serialize()); - test_enc_dec(sk, pk); - test_enc_dec_big(sk, pk); + super::tests::test_compressed_public(); + super::tests::test_uncompressed_public(); + } + + #[wasm_bindgen_test] + fn test_wasm_error() { + super::tests::attempts_to_encrypt_with_invalid_key(); + super::tests::attempts_to_decrypt_with_invalid_key(); + super::tests::attempts_to_decrypt_incorrect_message(); + super::tests::attempts_to_decrypt_with_another_key(); } } diff --git a/src/symmetric/aead.rs b/src/symmetric/aead.rs new file mode 100644 index 0000000..a18485a --- /dev/null +++ b/src/symmetric/aead.rs @@ -0,0 +1,71 @@ +#[allow(unused_imports)] +#[cfg(all(feature = "pure", not(feature = "xchacha20")))] +use typenum::consts::{U12, U16}; + +#[cfg(all(feature = "pure", not(feature = "xchacha20")))] +use aes_gcm::{ + aead::{generic_array::GenericArray, AeadInPlace}, + aes::Aes256, + AesGcm, KeyInit, +}; +#[cfg(all(feature = "xchacha20", not(feature = "pure")))] +use chacha20poly1305::{ + aead::{generic_array::GenericArray, AeadInPlace}, + KeyInit, XChaCha20Poly1305, +}; + +#[cfg(all(feature = "pure", not(feature = "aes-12bytes-nonce")))] +type Cipher = AesGcm; +#[cfg(all(feature = "pure", feature = "aes-12bytes-nonce"))] +type Cipher = AesGcm; +#[cfg(feature = "xchacha20")] +type Cipher = XChaCha20Poly1305; + +use crate::compat::Vec; +use crate::consts::{AEAD_TAG_LENGTH, EMPTY_BYTES, NONCE_LENGTH, NONCE_TAG_LENGTH}; + +/// Pure Rust AES-256-GCM or XChaCha20-Poly1305 encryption wrapper. +/// Maximum message size: 64GB (AES) or 256GB (XChaCha20). +/// +/// It's basically safe to just `unwrap` the returned `Option>`. +pub fn encrypt(key: &[u8], nonce: &[u8], msg: &[u8]) -> Option> { + let key = GenericArray::from_slice(key); + let aead = Cipher::new(key); + + let mut output = Vec::with_capacity(NONCE_TAG_LENGTH + msg.len()); + output.extend(nonce); + output.extend([0u8; AEAD_TAG_LENGTH]); + output.extend(msg); + + let nonce = GenericArray::from_slice(nonce); + if let Ok(tag) = aead.encrypt_in_place_detached(nonce, &EMPTY_BYTES, &mut output[NONCE_TAG_LENGTH..]) { + output[NONCE_LENGTH..NONCE_TAG_LENGTH].copy_from_slice(tag.as_slice()); + Some(output) + } else { + None + } +} + +/// Pure Rust AES-256-GCM or XChaCha20-Poly1305 decryption wrapper +pub fn decrypt(key: &[u8], encrypted: &[u8]) -> Option> { + if encrypted.len() < NONCE_TAG_LENGTH { + return None; + } + let key = GenericArray::from_slice(key); + let aead = Cipher::new(key); + + let nonce = GenericArray::from_slice(&encrypted[..NONCE_LENGTH]); + let tag = GenericArray::from_slice(&encrypted[NONCE_LENGTH..NONCE_TAG_LENGTH]); + + let mut out = Vec::with_capacity(encrypted.len() - NONCE_TAG_LENGTH); + out.extend(&encrypted[NONCE_TAG_LENGTH..]); + + if aead + .decrypt_in_place_detached(nonce, &EMPTY_BYTES, &mut out, tag) + .is_ok() + { + Some(out) + } else { + None + } +} diff --git a/src/symmetric/mod.rs b/src/symmetric/mod.rs index fe7b44f..5b08a5f 100644 --- a/src/symmetric/mod.rs +++ b/src/symmetric/mod.rs @@ -1,108 +1,119 @@ +use rand_core::{OsRng, RngCore}; + +use crate::compat::Vec; +use crate::consts::NONCE_LENGTH; + +#[cfg(any(feature = "pure", feature = "xchacha20"))] +mod aead; #[cfg(feature = "openssl")] mod openssl_aes; -#[cfg(feature = "pure")] -mod pure_aes; -#[cfg(feature = "xchacha20")] -mod xchacha20; +#[cfg(any(feature = "pure", feature = "xchacha20"))] +use aead::{decrypt, encrypt}; #[cfg(feature = "openssl")] use openssl_aes::{decrypt, encrypt}; -#[cfg(feature = "pure")] -use pure_aes::{decrypt, encrypt}; -#[cfg(feature = "xchacha20")] -use xchacha20::{decrypt, encrypt}; - -use crate::compat::Vec; /// Symmetric encryption wrapper. Openssl AES-256-GCM, pure Rust AES-256-GCM, or XChaCha20-Poly1305 +/// Nonces are generated randomly. +/// +/// For 16 bytes nonce AES-256-GCM and 24 bytes nonce XChaCha20-Poly1305 it's safe. +/// For 12 bytes nonce AES-256-GCM, the key SHOULD be unique for each message to avoid collisions. pub fn sym_encrypt(key: &[u8], msg: &[u8]) -> Option> { - encrypt(key, msg) + let mut nonce = [0u8; NONCE_LENGTH]; + OsRng.fill_bytes(&mut nonce); + encrypt(key, &nonce, msg) } /// Symmetric decryption wrapper -pub fn sym_decrypt(key: &[u8], encrypted_msg: &[u8]) -> Option> { - decrypt(key, encrypted_msg) +pub fn sym_decrypt(key: &[u8], encrypted: &[u8]) -> Option> { + decrypt(key, encrypted) } #[cfg(test)] -pub(crate) mod tests { - use hex::decode; // dev dep - use rand_core::{OsRng, RngCore}; - +mod tests { 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() - } + use crate::{consts::NONCE_TAG_LENGTH, utils::tests::decode_hex}; #[test] - fn test_attempt_to_decrypt_invalid_message() { + pub(super) fn attempts_to_decrypt_invalid_message() { assert!(decrypt(&[], &[]).is_none()); - assert!(decrypt(&[], &[0; 16]).is_none()); + assert!(decrypt(&[], &[1u8; 16]).is_none()); + assert!(decrypt(&[], &[1u8; NONCE_TAG_LENGTH - 1]).is_none()); } #[test] - fn test_random_key() { - let text = b"this is a text"; + pub(super) fn test_random_key() { let mut key = [0u8; 32]; - OsRng.fill_bytes(&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() - ); + + let texts = [b"this is a text", "😀😀😀😀".as_bytes()]; + for msg in texts.iter() { + OsRng.fill_bytes(&mut key); + let encrypted = sym_encrypt(&key, msg).unwrap(); + assert_eq!(msg.to_vec(), sym_decrypt(&key, &encrypted).unwrap()); + } } #[test] #[cfg(all(not(feature = "aes-12bytes-nonce"), not(feature = "xchacha20")))] - fn test_aes_known_key() { + pub(super) fn test_aes_known_key() { let text = b"helloworld"; let key = decode_hex("0000000000000000000000000000000000000000000000000000000000000000"); - let iv = decode_hex("f3e1ba810d2c8900b11312b7c725565f"); + let nonce = 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); + check_known(text, &key, &nonce, &tag, &encrypted); + } - assert_eq!(text, decrypt(&key, &cipher_text).unwrap().as_slice()); + #[test] + #[cfg(all(feature = "aes-12bytes-nonce", not(feature = "xchacha20")))] + pub(super) fn test_aes_known_key() { + let text = b""; + let key = decode_hex("0000000000000000000000000000000000000000000000000000000000000000"); + let nonce = decode_hex("000000000000000000000000"); + let tag = decode_hex("530f8afbc74536b9a963b4f1c4cb738b"); + let encrypted = decode_hex(""); + + check_known(text, &key, &nonce, &tag, &encrypted); } #[test] #[cfg(feature = "xchacha20")] - fn test_xchacha20_known_key() { + pub(super) 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); + check_known(text, &key, &nonce, &tag, &encrypted); + } + + fn check_known(msg: &[u8], key: &[u8], nonce: &[u8], tag: &[u8], encrypted: &[u8]) { + let mut cipher_text = Vec::new(); cipher_text.extend(nonce); cipher_text.extend(tag); cipher_text.extend(encrypted); + assert_eq!(msg, &sym_decrypt(key, &cipher_text).unwrap()); + assert_eq!(cipher_text, encrypt(key, nonce, msg).unwrap()); + } +} + +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use wasm_bindgen_test::*; + + #[wasm_bindgen_test] + fn test_wasm() { + super::tests::test_random_key(); + #[cfg(all(not(feature = "aes-12bytes-nonce"), not(feature = "xchacha20")))] + super::tests::test_aes_known_key(); + #[cfg(feature = "xchacha20")] + super::tests::test_xchacha20_known_key(); + } - assert_eq!(text, sym_decrypt(&key, &cipher_text).unwrap().as_slice()); + #[wasm_bindgen_test] + fn test_wasm_error() { + super::tests::attempts_to_decrypt_invalid_message(); } } diff --git a/src/symmetric/openssl_aes.rs b/src/symmetric/openssl_aes.rs index 9562038..3df24a1 100644 --- a/src/symmetric/openssl_aes.rs +++ b/src/symmetric/openssl_aes.rs @@ -1,26 +1,20 @@ use openssl::symm::{decrypt_aead, encrypt_aead, Cipher}; -use rand_core::{OsRng, RngCore}; -use crate::consts::{AEAD_TAG_LENGTH, AES_NONCE_LENGTH, EMPTY_BYTES}; +use crate::consts::{AEAD_TAG_LENGTH, EMPTY_BYTES, NONCE_LENGTH, NONCE_TAG_LENGTH}; use crate::Vec; -const NONCE_TAG_LENGTH: usize = AES_NONCE_LENGTH + AEAD_TAG_LENGTH; - /// AES-256-GCM encryption wrapper -pub fn encrypt(key: &[u8], msg: &[u8]) -> Option> { +pub fn encrypt(key: &[u8], nonce: &[u8], msg: &[u8]) -> Option> { let cipher = Cipher::aes_256_gcm(); - let mut iv = [0u8; AES_NONCE_LENGTH]; - OsRng.fill_bytes(&mut iv); + let mut output = Vec::with_capacity(NONCE_TAG_LENGTH + msg.len()); + output.extend(nonce); + output.extend([0u8; AEAD_TAG_LENGTH]); - let mut tag = [0u8; AEAD_TAG_LENGTH]; + let tag = &mut output[NONCE_LENGTH..NONCE_TAG_LENGTH]; - if let Ok(encrypted) = encrypt_aead(cipher, key, Some(&iv), &EMPTY_BYTES, msg, &mut tag) { - let mut output = Vec::with_capacity(NONCE_TAG_LENGTH + encrypted.len()); - output.extend(&iv); - output.extend(&tag); + if let Ok(encrypted) = encrypt_aead(cipher, key, Some(nonce), &EMPTY_BYTES, msg, tag) { output.extend(encrypted); - Some(output) } else { None @@ -28,16 +22,15 @@ pub fn encrypt(key: &[u8], msg: &[u8]) -> Option> { } /// AES-256-GCM decryption wrapper -pub fn decrypt(key: &[u8], encrypted_msg: &[u8]) -> Option> { - if encrypted_msg.len() < NONCE_TAG_LENGTH { +pub fn decrypt(key: &[u8], encrypted: &[u8]) -> Option> { + if encrypted.len() < NONCE_TAG_LENGTH { return None; } let cipher = Cipher::aes_256_gcm(); - let iv = &encrypted_msg[..AES_NONCE_LENGTH]; - let tag = &encrypted_msg[AES_NONCE_LENGTH..NONCE_TAG_LENGTH]; - let encrypted = &encrypted_msg[NONCE_TAG_LENGTH..]; - - decrypt_aead(cipher, key, Some(iv), &EMPTY_BYTES, encrypted, tag).ok() + let nonce = &encrypted[..NONCE_LENGTH]; + let tag = &encrypted[NONCE_LENGTH..NONCE_TAG_LENGTH]; + let encrypted = &encrypted[NONCE_TAG_LENGTH..]; + decrypt_aead(cipher, key, Some(nonce), &EMPTY_BYTES, encrypted, tag).ok() } diff --git a/src/symmetric/pure_aes.rs b/src/symmetric/pure_aes.rs deleted file mode 100644 index 0ccbfd7..0000000 --- a/src/symmetric/pure_aes.rs +++ /dev/null @@ -1,63 +0,0 @@ -use aes_gcm::{ - aead::{generic_array::GenericArray, AeadInPlace}, - aes::Aes256, - AesGcm, KeyInit, -}; -use rand_core::{OsRng, RngCore}; -#[allow(unused_imports)] -use typenum::consts::{U12, U16}; - -use crate::compat::Vec; -use crate::consts::{AEAD_TAG_LENGTH, AES_NONCE_LENGTH, EMPTY_BYTES}; - -#[cfg(not(feature = "aes-12bytes-nonce"))] -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 encrypt(key: &[u8], msg: &[u8]) -> Option> { - let key = GenericArray::from_slice(key); - let aead = Aes256Gcm::new(key); - - let mut iv = [0u8; AES_NONCE_LENGTH]; - OsRng.fill_bytes(&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 - } -} - -/// AES-256-GCM 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 = Aes256Gcm::new(key); - - let iv = GenericArray::from_slice(&encrypted_msg[..AES_NONCE_LENGTH]); - let tag = GenericArray::from_slice(&encrypted_msg[AES_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/symmetric/xchacha20.rs b/src/symmetric/xchacha20.rs deleted file mode 100644 index 0c1a5ee..0000000 --- a/src/symmetric/xchacha20.rs +++ /dev/null @@ -1,54 +0,0 @@ -use chacha20poly1305::{ - aead::{generic_array::GenericArray, AeadInPlace}, - KeyInit, XChaCha20Poly1305, -}; -use rand_core::{OsRng, RngCore}; - -use crate::compat::Vec; -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]; - OsRng.fill_bytes(&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/utils.rs b/src/utils.rs index b3e456f..5e890fa 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -22,7 +22,7 @@ pub fn encapsulate(sk: &SecretKey, peer_pk: &PublicKey) -> Result Result Result { +fn derive_key(pk: &PublicKey, shared_point: &PublicKey, is_compressed: bool) -> SharedSecret { let key_size = get_ephemeral_key_size(); let mut master = Vec::with_capacity(key_size * 2); - if is_hkdf_key_compressed() { - master.extend(pk.serialize_compressed().iter()); - master.extend(shared_point.serialize_compressed().iter()); + if is_compressed { + master.extend(&pk.serialize_compressed()); + master.extend(&shared_point.serialize_compressed()); } else { - master.extend(pk.serialize().iter()); - master.extend(shared_point.serialize().iter()); + master.extend(&pk.serialize()); + master.extend(&shared_point.serialize()); } - hkdf_sha256(master.as_slice()) + hkdf_sha256(&master) } -fn hkdf_sha256(master: &[u8]) -> Result { +fn hkdf_sha256(master: &[u8]) -> SharedSecret { let h = Hkdf::::new(None, master); let mut out = [0u8; 32]; - h.expand(&EMPTY_BYTES, &mut out) - .map_err(|_| SecpError::InvalidInputLength)?; - Ok(out) + // never fails because 32 < 255 * chunk_len, which is 32 on SHA256 + h.expand(&EMPTY_BYTES, &mut out).unwrap(); + out } #[cfg(test)] -mod tests { - use libsecp256k1::Error; +pub mod tests { + use hex::decode; // dev dep use super::*; - use crate::symmetric::tests::decode_hex; + + /// Convert hex string to u8 vector + pub fn decode_hex(hex: &str) -> Vec { + let hex = if hex.starts_with("0x") || hex.starts_with("0X") { + &hex[2..] + } else { + hex + }; + decode(hex).unwrap() + } #[test] - fn test_generate_keypair() { + fn test_key_exchange() { let (sk1, pk1) = generate_keypair(); let (sk2, pk2) = generate_keypair(); assert_ne!(sk1, sk2); assert_ne!(pk1, pk2); + assert_eq!(encapsulate(&sk2, &pk1).unwrap(), decapsulate(&pk2, &sk1).unwrap()); } #[test] - fn test_valid_secret() { - // 0 < private key < group order int is valid - let zero = [0u8; 32]; - assert_eq!(SecretKey::parse_slice(&zero).err().unwrap(), Error::InvalidSecretKey); - - let group_order_minus_1 = decode_hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140"); - SecretKey::parse_slice(&group_order_minus_1).unwrap(); - - let group_order = decode_hex("fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); - assert_eq!( - SecretKey::parse_slice(&group_order).err().unwrap(), - Error::InvalidSecretKey - ); + fn test_secret_validity() { + // 0 < private key < group order is valid + let mut zero = [0u8; 32]; + let group_order = decode_hex("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + let invalid_sks = [zero.to_vec(), group_order]; + + for sk in invalid_sks.iter() { + assert_eq!(SecretKey::parse_slice(sk).err().unwrap(), SecpError::InvalidSecretKey); + } + + zero[31] = 1; + + let one = zero; + let group_order_minus_1 = decode_hex("0Xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364140"); + let valid_sks = [one.to_vec(), group_order_minus_1]; + for sk in valid_sks.iter() { + SecretKey::parse_slice(sk).unwrap(); + } } + #[test] fn test_known_hkdf_vector() { - let text = b"secret"; - assert_eq!( - hkdf_sha256(text).unwrap().to_vec(), + hkdf_sha256(b"secret").to_vec(), decode_hex("2f34e5ff91ec85d53ca9b543683174d0cf550b60d5f52b24c97b386cfcf6cbbf") ); } - #[test] - fn test_known_shared_secret() { + /// Generate two secret keys with values 2 and 3 + pub fn get_sk2_sk3() -> (SecretKey, SecretKey) { let mut two = [0u8; 32]; let mut three = [0u8; 32]; two[31] = 2u8; three[31] = 3u8; let sk2 = SecretKey::parse_slice(&two).unwrap(); - let pk2 = PublicKey::from_secret_key(&sk2); let sk3 = SecretKey::parse_slice(&three).unwrap(); + (sk2, sk3) + } + + #[test] + pub(super) fn test_known_shared_secret() { + let (sk2, sk3) = get_sk2_sk3(); let pk3 = PublicKey::from_secret_key(&sk3); - assert_eq!(encapsulate(&sk2, &pk3), decapsulate(&pk2, &sk3)); assert_eq!( encapsulate(&sk2, &pk3).unwrap().to_vec(), decode_hex("6f982d63e8590c9d9b5b4c1959ff80315d772edd8f60287c9361d548d5200f82") ); } } + +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use wasm_bindgen_test::*; + + #[wasm_bindgen_test] + fn test_wasm() { + super::tests::test_known_shared_secret(); + } +} diff --git a/tests/integration.rs b/tests/integration.rs index e8f69df..1adfa23 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,74 +1,3 @@ -use ecies::{ - config::{reset_config, update_config, Config}, - decrypt, encrypt, - utils::{decapsulate, encapsulate, generate_keypair}, - PublicKey, SecretKey, -}; -use hex::decode; - -const MSG: &str = "helloworld🌍"; - -fn check_config_hkdf_key() { - let mut two = [0u8; 32]; - let mut three = [0u8; 32]; - two[31] = 2u8; - three[31] = 3u8; - - let sk2 = SecretKey::parse_slice(&two).unwrap(); - let pk2 = PublicKey::from_secret_key(&sk2); - let sk3 = SecretKey::parse_slice(&three).unwrap(); - let pk3 = PublicKey::from_secret_key(&sk3); - - update_config(Config { - is_hkdf_key_compressed: true, - ..Config::default() - }); - - let encapsulated = encapsulate(&sk2, &pk3).unwrap(); - - assert_eq!(encapsulated, decapsulate(&pk2, &sk3).unwrap()); - assert_eq!( - encapsulated.to_vec(), - decode("b192b226edb3f02da11ef9c6ce4afe1c7e40be304e05ae3b988f4834b1cb6c69").unwrap() - ); -} - -fn check_config_ephemeral_key() { - let (sk, pk) = generate_keypair(); - let (sk, pk) = (&sk.serialize(), &pk.serialize_compressed()); - let encrypted_1 = encrypt(pk, MSG.as_bytes()).unwrap(); - assert_eq!(MSG.as_bytes(), decrypt(sk, &encrypted_1).unwrap().as_slice()); - - update_config(Config { - is_ephemeral_key_compressed: true, - ..Config::default() - }); - - let encrypted_2 = encrypt(pk, MSG.as_bytes()).unwrap(); - assert_eq!(encrypted_1.len() - encrypted_2.len(), 32); - assert_eq!(MSG.as_bytes(), decrypt(sk, &encrypted_2).unwrap().as_slice()); - - reset_config(); -} - -#[test] -fn can_change_behavior_with_config() { - check_config_hkdf_key(); - check_config_ephemeral_key(); -} - -#[cfg(all(test, target_arch = "wasm32"))] -mod wasm_tests { - use super::*; - use wasm_bindgen_test::*; - - #[wasm_bindgen_test] - fn can_change_behavior_with_config() { - check_config_hkdf_key(); - check_config_ephemeral_key(); - } -} - #[test] #[cfg(all( not(target_arch = "wasm32"), @@ -77,9 +6,12 @@ mod wasm_tests { ))] fn is_compatible_with_python() { use futures_util::FutureExt; - use hex::encode; + use hex::{decode, encode}; use tokio::runtime::Runtime; + use ecies::{decrypt, encrypt, utils::generate_keypair}; + + const MSG: &str = "helloworld🌍"; const PYTHON_BACKEND: &str = "https://eciespydemo-1-d5397785.deta.app/"; let (sk, pk) = generate_keypair(); @@ -103,7 +35,7 @@ fn is_compatible_with_python() { .unwrap(); let server_encrypted = decode(res).unwrap(); - let local_decrypted = decrypt(&sk.serialize(), server_encrypted.as_slice()).unwrap(); + let local_decrypted = decrypt(&sk.serialize(), &server_encrypted).unwrap(); assert_eq!(local_decrypted, MSG.as_bytes()); let local_encrypted = encrypt(uncompressed_pk, MSG.as_bytes()).unwrap();