diff --git a/Cargo.toml b/Cargo.toml index de17d9a..5fa543a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,10 @@ authors = ["Edouard Oger "] license = "MPL-2.0" [dependencies] -byteorder = "1.2.6" -failure = "0.1.2" -failure_derive = "0.1.2" +byteorder = "1.2.7" +failure = "0.1.5" +failure_derive = "0.1.5" +base64 = "0.10" [dependencies.ece-crypto] path = "crypto" diff --git a/crypto/Cargo.toml b/crypto/Cargo.toml index 6dd5d3e..3063803 100644 --- a/crypto/Cargo.toml +++ b/crypto/Cargo.toml @@ -5,4 +5,5 @@ authors = ["Edouard Oger "] license = "MPL-2.0" [dependencies] -failure = "0.1.2" +failure = "0.1.5" +base64 = "0.10" diff --git a/crypto/openssl/src/lib.rs b/crypto/openssl/src/lib.rs index 31106cc..c9f114c 100644 --- a/crypto/openssl/src/lib.rs +++ b/crypto/openssl/src/lib.rs @@ -96,6 +96,12 @@ impl LocalKeyPair for OpenSSLLocalKeyPair { } } +impl From> for OpenSSLLocalKeyPair { + fn from(key: EcKey) -> OpenSSLLocalKeyPair { + OpenSSLLocalKeyPair { ec_key: key } + } +} + pub struct OpenSSLCrypto; impl Crypto for OpenSSLCrypto { type RemotePublicKey = OpenSSLRemotePublicKey; diff --git a/crypto/src/lib.rs b/crypto/src/lib.rs index 5342ebc..b3ad586 100644 --- a/crypto/src/lib.rs +++ b/crypto/src/lib.rs @@ -12,7 +12,9 @@ pub trait RemotePublicKey { } pub trait LocalKeyPair { - fn generate_random() -> Result where Self: Sized; + fn generate_random() -> Result + where + Self: Sized; /// Export the public key component in the /// binary uncompressed point representation. fn pub_as_raw(&self) -> Result>; diff --git a/src/aes128gcm.rs b/src/aes128gcm.rs index 6c4e220..0795955 100644 --- a/src/aes128gcm.rs +++ b/src/aes128gcm.rs @@ -9,44 +9,17 @@ use error::*; const ECE_AES128GCM_MIN_RS: u32 = 18; const ECE_AES128GCM_HEADER_LENGTH: usize = 21; -const ECE_AES128GCM_MAX_KEY_ID_LENGTH: usize = 255; +// The max AES128GCM Key ID Length is 255 octets. We use far less of that because we use +// the "key_id" to store the exchanged public key since we don't cache the key_ids. +// Code fails if the key_id is not a public key length field. const ECE_AES128GCM_PAD_SIZE: usize = 1; -const ECE_WEBPUSH_AES128GCM_IKM_INFO_PREFIX: &'static str = "WebPush: info\0"; +const ECE_WEBPUSH_AES128GCM_IKM_INFO_PREFIX: &str = "WebPush: info\0"; const ECE_WEBPUSH_AES128GCM_IKM_INFO_LENGTH: usize = 144; // 14 (prefix len) + 65 (pub key len) * 2; -const ECE_WEBPUSH_DEFAULT_RS: u32 = 4096; const ECE_WEBPUSH_IKM_LENGTH: usize = 32; -const ECE_AES128GCM_KEY_INFO: &'static str = "Content-Encoding: aes128gcm\0"; -const ECE_AES128GCM_NONCE_INFO: &'static str = "Content-Encoding: nonce\0"; - -// TODO: Make it nicer to use with a builder pattern. -pub struct WebPushParams { - pub rs: u32, - pub pad_length: usize, - pub salt: Option>, -} - -impl WebPushParams { - /// Random salt, record size = 4096 and padding length = 0. - pub fn default() -> Self { - Self { - rs: ECE_WEBPUSH_DEFAULT_RS, - pad_length: 0, - salt: None, - } - } - - /// Never use the same salt twice as it will derive the same content encryption - /// key for multiple messages if the same sender private key is used! - pub fn new(rs: u32, pad_length: usize, salt: Vec) -> Self { - Self { - rs, - pad_length, - salt: Some(salt), - } - } -} +const ECE_AES128GCM_KEY_INFO: &str = "Content-Encoding: aes128gcm\0"; +const ECE_AES128GCM_NONCE_INFO: &str = "Content-Encoding: nonce\0"; // TODO: When done, remove the aes128gcm prefixes and the EC_ ones. // As for now it makes it easier to Ctrl + F into ecec :) diff --git a/src/aesgcm.rs b/src/aesgcm.rs new file mode 100644 index 0000000..dc0c075 --- /dev/null +++ b/src/aesgcm.rs @@ -0,0 +1,263 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * This supports the now obsolete HTTP-ECE Draft 02 "aesgcm" content + * type. There are a number of providers that still use this format, + * and there's no real mechanism to return the client supported crypto + * versions. + * + * */ + +use base64; +use std::collections::HashMap; + +use common::{ + ece_min_block_pad_length, EceMode, EceWebPush, KeyAndNonce, WebPushParams, ECE_AES_KEY_LENGTH, + ECE_NONCE_LENGTH, ECE_SALT_LENGTH, ECE_TAG_LENGTH, +}; +use ece_crypto::{Crypto, LocalKeyPair, RemotePublicKey}; +use error::{ErrorKind, Result}; + +const ECE_AESGCM_PAD_SIZE: usize = 2; + +const ECE_WEBPUSH_AESGCM_KEYPAIR_LENGTH: usize = 134; // (2 + Raw Key Length) * 2 +const ECE_WEBPUSH_AESGCM_AUTHINFO: &str = "Content-Encoding: auth\0"; + +// a DER prefixed key is "\04" + ECE_WEBPUSH_RAW_KEY_LENGTH +const ECE_WEBPUSH_RAW_KEY_LENGTH: usize = 65; +const ECE_WEBPUSH_IKM_LENGTH: usize = 32; + +pub struct AesGcmEncryptedBlock { + pub dh: Vec, + pub salt: Vec, + pub rs: u32, + pub ciphertext: Vec, +} + +impl AesGcmEncryptedBlock { + pub fn aesgcm_rs(rs: u32) -> u32 { + if rs > u32::max_value() - ECE_TAG_LENGTH as u32 { + return 0; + } + rs + ECE_TAG_LENGTH as u32 + } + + /// Create a new block from the various header strings and body content. + pub fn new( + dh: &Vec, + salt: &Vec, + rs: u32, + ciphertext: Vec, + ) -> Result { + Ok(AesGcmEncryptedBlock { + dh: dh.to_owned(), + salt: salt.to_owned(), + rs: Self::aesgcm_rs(rs), + ciphertext, + }) + } + + /// Return the headers Hash, NOTE you may need to merge Crypto-Key if there's + /// already a VAPID element present. + pub fn headers(self) -> HashMap { + let mut result: HashMap = HashMap::new(); + let mut rs = "".to_owned(); + result.insert( + "Crypto-Key".to_owned(), + format!( + "dh={}", + base64::encode_config(&self.dh, base64::URL_SAFE_NO_PAD) + ), + ); + if self.rs > 0 { + rs = format!(";rs={}", self.rs); + } + result.insert( + "Encryption".to_owned(), + format!( + "salt={}{}", + base64::encode_config(&self.salt, base64::URL_SAFE_NO_PAD), + rs + ), + ); + result + } + + /// Encode the body as a String. + /// If you need the bytes, probably just call .ciphertext directly + pub fn body(self) -> String { + base64::encode_config(&self.ciphertext, base64::URL_SAFE_NO_PAD) + } +} + +pub struct AesGcmEceWebPush { + _marker1: ::std::marker::PhantomData, + _marker2: ::std::marker::PhantomData, + _marker3: ::std::marker::PhantomData, +} + +impl AesGcmEceWebPush +where + L: LocalKeyPair, + R: RemotePublicKey, + C: Crypto, +{ + /// Encrypts a Web Push message using the "aesgcm" scheme. This function + /// automatically generates an ephemeral ECDH key pair. + pub fn encrypt( + remote_pub_key: &R, + auth_secret: &[u8], + plaintext: &[u8], + params: WebPushParams, + ) -> Result { + let local_prv_key = C::generate_ephemeral_keypair()?; + Self::encrypt_with_keys( + &local_prv_key, + remote_pub_key, + auth_secret, + plaintext, + params, + ) + } + + /// Encrypts a Web Push message using the "aesgcm" scheme, with an explicit + /// sender key. The sender key can be reused. + pub fn encrypt_with_keys( + local_prv_key: &L, + remote_pub_key: &R, + auth_secret: &[u8], + plaintext: &[u8], + params: WebPushParams, + ) -> Result { + let salt = { + let mut salt = [0u8; ECE_SALT_LENGTH]; + C::random(&mut salt)?; + salt.to_vec() + }; + let raw_local_pub_key = local_prv_key.pub_as_raw()?; + let ciphertext = Self::common_encrypt( + local_prv_key, + remote_pub_key, + auth_secret, + &salt, + params.rs, + params.pad_length, + plaintext, + )?; + Ok(AesGcmEncryptedBlock { + salt, + dh: raw_local_pub_key, + rs: params.rs, + ciphertext, + }) + } + + /// Decrypts a Web Push message encrypted using the "aesgcm" scheme. + pub fn decrypt( + local_prv_key: &L, + auth_secret: &[u8], + block: &AesGcmEncryptedBlock, + ) -> Result> { + let sender_key = C::public_key_from_raw(&block.dh)?; + Self::common_decrypt( + local_prv_key, + &sender_key, + auth_secret, + &block.salt, + block.rs, + &block.ciphertext, + ) + } +} + +impl EceWebPush for AesGcmEceWebPush +where + L: LocalKeyPair, + R: RemotePublicKey, + C: Crypto, +{ + type Crypto = C; + type LocalKeyPair = L; + type RemotePublicKey = R; + + fn needs_trailer(rs: u32, ciphertextlen: usize) -> bool { + ciphertextlen as u32 % rs == 0 + } + + fn pad_size() -> usize { + ECE_AESGCM_PAD_SIZE + } + + fn min_block_pad_length(pad_len: usize, max_block_len: usize) -> usize { + ece_min_block_pad_length(pad_len, max_block_len) + } + + fn pad(plaintext: &[u8], _: usize, _: bool) -> Result> { + let plen = plaintext.len(); + let mut block = vec![0; plen + ECE_AESGCM_PAD_SIZE]; + block[2..].copy_from_slice(plaintext); + Ok(block) + } + + fn unpad(block: &[u8], _: bool) -> Result<&[u8]> { + Ok(&block[2..]) + } + + /// Derives the "aesgcm" decryption keyn and nonce given the receiver private + /// key, sender public key, authentication secret, and sender salt. + fn derive_key_and_nonce( + ece_mode: EceMode, + local_prv_key: &Self::LocalKeyPair, + remote_pub_key: &Self::RemotePublicKey, + auth_secret: &[u8], + salt: &[u8], + ) -> Result { + let shared_secret = Self::Crypto::compute_ecdh_secret(remote_pub_key, local_prv_key)?; + let raw_remote_pub_key = remote_pub_key.as_raw()?; + let raw_local_pub_key = local_prv_key.pub_as_raw()?; + + let keypair = match ece_mode { + EceMode::ENCRYPT => encode_keys(&raw_remote_pub_key, &raw_local_pub_key), + EceMode::DECRYPT => encode_keys(&raw_local_pub_key, &raw_remote_pub_key), + }?; + let keyinfo = generate_info("aesgcm", &keypair)?; + let nonceinfo = generate_info("nonce", &keypair)?; + let ikm = Self::Crypto::hkdf_sha256( + auth_secret, + &shared_secret, + &ECE_WEBPUSH_AESGCM_AUTHINFO.as_bytes(), + ECE_WEBPUSH_IKM_LENGTH, + )?; + let key = Self::Crypto::hkdf_sha256(salt, &ikm, &keyinfo, ECE_AES_KEY_LENGTH)?; + let nonce = Self::Crypto::hkdf_sha256(salt, &ikm, &nonceinfo, ECE_NONCE_LENGTH)?; + Ok((key, nonce)) + } +} + +fn encode_keys(raw_key1: &[u8], raw_key2: &[u8]) -> Result> { + let mut combined = vec![0u8; ECE_WEBPUSH_AESGCM_KEYPAIR_LENGTH]; + + if raw_key1.len() > ECE_WEBPUSH_RAW_KEY_LENGTH || raw_key2.len() > ECE_WEBPUSH_RAW_KEY_LENGTH { + return Err(ErrorKind::InvalidKeyLength.into()); + } + // length prefix each key + combined[0] = 0; + combined[1] = 65; + combined[2..67].copy_from_slice(raw_key1); + combined[67] = 0; + combined[68] = 65; + combined[69..].copy_from_slice(raw_key2); + Ok(combined) +} + +// The "aesgcm" IKM info string is "WebPush: info", followed by the +// receiver and sender public keys prefixed by their lengths. +fn generate_info(encoding: &str, keypair: &[u8]) -> Result> { + let info_str = format!("Content-Encoding: {}\0P-256\0", encoding); + let offset = info_str.len(); + let mut info = vec![0u8; offset + keypair.len()]; + info[0..offset].copy_from_slice(info_str.as_bytes()); + info[offset..offset + ECE_WEBPUSH_AESGCM_KEYPAIR_LENGTH].copy_from_slice(keypair); + Ok(info) +} diff --git a/src/common.rs b/src/common.rs index 6c5547f..0d868d3 100644 --- a/src/common.rs +++ b/src/common.rs @@ -13,13 +13,39 @@ pub const ECE_NONCE_LENGTH: usize = 12; // From ece.h: pub const ECE_SALT_LENGTH: usize = 16; -const ECE_TAG_LENGTH: usize = 16; -const ECE_WEBPUSH_PRIVATE_KEY_LENGTH: usize = 32; +pub const ECE_TAG_LENGTH: usize = 16; +//const ECE_WEBPUSH_PRIVATE_KEY_LENGTH: usize = 32; pub const ECE_WEBPUSH_PUBLIC_KEY_LENGTH: usize = 65; const ECE_WEBPUSH_AUTH_SECRET_LENGTH: usize = 16; +const ECE_WEBPUSH_DEFAULT_RS: u32 = 4096; -const ECE_AESGCM_MIN_RS: u8 = 3; -const ECE_AESGCM_PAD_SIZE: u8 = 2; +// TODO: Make it nicer to use with a builder pattern. +pub struct WebPushParams { + pub rs: u32, + pub pad_length: usize, + pub salt: Option>, +} + +impl WebPushParams { + /// Random salt, record size = 4096 and padding length = 0. + pub fn default() -> Self { + Self { + rs: ECE_WEBPUSH_DEFAULT_RS, + pad_length: 2, + salt: None, + } + } + + /// Never use the same salt twice as it will derive the same content encryption + /// key for multiple messages if the same sender private key is used! + pub fn new(rs: u32, pad_length: usize, salt: Vec) -> Self { + Self { + rs, + pad_length, + salt: Some(salt), + } + } +} pub enum EceMode { ENCRYPT, @@ -48,7 +74,7 @@ pub trait EceWebPush { if salt.len() != ECE_SALT_LENGTH { return Err(ErrorKind::InvalidSalt.into()); } - if plaintext.len() == 0 { + if plaintext.is_empty() { return Err(ErrorKind::ZeroPlaintext.into()); } let (key, nonce) = Self::derive_key_and_nonce( @@ -143,7 +169,7 @@ pub trait EceWebPush { if salt.len() != ECE_SALT_LENGTH { return Err(ErrorKind::InvalidSalt.into()); } - if ciphertext.len() == 0 { + if ciphertext.is_empty() { return Err(ErrorKind::ZeroCiphertext.into()); } if Self::needs_trailer(rs, ciphertext.len()) { @@ -170,6 +196,7 @@ pub trait EceWebPush { let block_len = record.len() - ECE_TAG_LENGTH; let data = &record[0..block_len]; let tag = &record[block_len..]; + // when this fails, it's always "OpenSSL error" let plaintext = Self::Crypto::aes_gcm_128_decrypt(&key, &iv, data, tag)?; let last_record = count == records_count - 1; if plaintext.len() < Self::pad_size() { @@ -208,11 +235,11 @@ pub fn ece_min_block_pad_length(pad_len: usize, max_block_len: usize) -> usize { // the padding first. block_pad_len += 1; } - return if block_pad_len > pad_len { + if block_pad_len > pad_len { pad_len } else { block_pad_len - }; + } } /// Generates a 96-bit IV, 48 bits of which are populated. diff --git a/src/error.rs b/src/error.rs index d578db7..b03782f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,6 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +use base64; use ece_crypto; use failure::{Backtrace, Context, Fail}; use std::boxed::Box; @@ -89,6 +90,9 @@ pub enum ErrorKind { #[fail(display = "Crypto error")] CryptoError, + + #[fail(display = "Could not decode base64 entry")] + DecodeError, } // This is bad design, however handling cross-crates errors @@ -106,3 +110,10 @@ impl From for Error { ErrorKind::from(e).into() } } + +impl From for Error { + #[inline] + fn from(_: base64::DecodeError) -> Error { + ErrorKind::DecodeError.into() + } +} diff --git a/src/lib.rs b/src/lib.rs index 97153a2..66fe773 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,18 +2,19 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +extern crate base64; extern crate byteorder; extern crate ece_crypto; extern crate failure; -#[macro_use] extern crate failure_derive; mod aes128gcm; -// TODO: mod aesgcm; +mod aesgcm; mod common; mod error; -pub use aes128gcm::WebPushParams; +pub use aesgcm::AesGcmEncryptedBlock; +pub use common::WebPushParams; pub use ece_crypto::{LocalKeyPair, RemotePublicKey}; pub use error::*; @@ -28,6 +29,13 @@ pub type Aes128GcmEceWebPush = aes128gcm::Aes128GcmEceWebPush< ece_crypto_openssl::OpenSSLCrypto, >; +#[cfg(feature = "openssl")] +pub type AesGcmEceWebPush = aesgcm::AesGcmEceWebPush< + OpenSSLLocalKeyPair, + OpenSSLRemotePublicKey, + ece_crypto_openssl::OpenSSLCrypto, +>; + #[cfg(test)] mod aes128gcm_tests { extern crate ece_crypto_openssl; @@ -233,3 +241,85 @@ mod aes128gcm_tests { }; } } + +// ===================== +#[cfg(test)] +mod aesgcm_tests { + extern crate base64; + extern crate ece_crypto_openssl; + extern crate hex; + + use super::*; + use aesgcm::AesGcmEncryptedBlock; + use ece_crypto::Crypto; + use ece_crypto_openssl::OpenSSLCrypto; + + fn try_decrypt( + priv_key: &str, + auth_secret: &str, + block: &AesGcmEncryptedBlock, + ) -> Result { + // The AesGcmEncryptedBlock is composed from the `Crypto-Key` & `Encryption` headers, and post body + // The Block will attempt to decode the base64 strings for dh & salt, so no additional action needed. + // Since the body is most likely not encoded, it is expected to be a raw buffer of [u8] + let priv_key_raw = base64::decode_config(priv_key, base64::URL_SAFE_NO_PAD)?; + let priv_key = OpenSSLLocalKeyPair::new(&priv_key_raw)?; + let auth_secret = base64::decode_config(auth_secret, base64::URL_SAFE_NO_PAD)?; + let plaintext = AesGcmEceWebPush::decrypt(&priv_key, &auth_secret, &block)?; + Ok(String::from_utf8(plaintext).unwrap()) + } + + #[test] + fn test_decode() { + // generated the content using pywebpush, which verified against the client. + let auth_raw = "LsuUOBKVQRY6-l7_Ajo-Ag"; + let priv_key_raw = "yerDmA9uNFoaUnSt2TkWWLwPseG1qtzS2zdjUl8Z7tc"; + //let pub_key_raw = "BLBlTYure2QVhJCiDt4gRL0JNmUBMxtNB5B6Z1hDg5h-Epw6mVFV4whoYGBlWNY-ENR1FObkGFyMf7-6ZMHMAxw"; + + // Incoming Crypto-Key: dh= + let dh = "BJvcyzf8ocm6F7lbFePebtXU7OHkmylXN9FL2g-yBHwUKqo6cD-FP1h5SHEQQ-xEgJl-F0xEEmSaEx2-qeJHYmk"; + // Incoming Encryption-Key: salt= + let salt = "8qX1ZgkLD50LHgocZdPKZQ"; + // Incoming Body (this is normally raw bytes. It's encoded here for presentation) + let ciphertext = base64::decode_config("8Vyes671P_VDf3G2e6MgY6IaaydgR-vODZZ7L0ZHbpCJNVaf_2omEms2tiPJiU22L3BoECKJixiOxihcsxWMjTgAcplbvfu1g6LWeP4j8dMAzJionWs7OOLif6jBKN6LGm4EUw9e26EBv9hNhi87-HaEGbfBMGcLvm1bql1F", + base64::URL_SAFE_NO_PAD).unwrap(); + let plaintext = "Amidst the mists and coldest frosts I thrust my fists against the\nposts and still demand to see the ghosts.\n"; + + let block = AesGcmEncryptedBlock::new( + &base64::decode_config(dh, base64::URL_SAFE_NO_PAD).unwrap(), + &base64::decode_config(salt, base64::URL_SAFE_NO_PAD).unwrap(), + 4096, + ciphertext, + ) + .unwrap(); + + let result = try_decrypt(priv_key_raw, auth_raw, &block).unwrap(); + + assert!(result == plaintext) + } + + #[test] + fn test_e2e() { + let (local_key, remote_key) = ece_crypto_openssl::generate_keys().unwrap(); + let plaintext = "When I grow up, I want to be a watermelon".as_bytes(); + let mut auth_secret = vec![0u8; 16]; + OpenSSLCrypto::random(&mut auth_secret).unwrap(); + let remote_public = + OpenSSLCrypto::public_key_from_raw(&remote_key.pub_as_raw().unwrap()).unwrap(); + let params = WebPushParams::default(); + let ciphertext = AesGcmEceWebPush::encrypt_with_keys( + &local_key, + &remote_public, + &auth_secret, + &plaintext, + params, + ) + .unwrap(); + let decrypted = AesGcmEceWebPush::decrypt(&remote_key, &auth_secret, &ciphertext).unwrap(); + assert_eq!(decrypted, plaintext); + } + + // If decode using externally validated data works, and e2e using the same decoder work, things + // should encode/decode. + // Other tests to be included if required, but skipping for now because of time constraints. +}