feat: Add aesgcm encrypted content support.

* Updated dependencies
* ran cargo fmt
* commented unused consts
* moved WebPushParams to `common.rs`
* minimal test case for aesgcm based on externally generated & valid
  data.

Closes #3
This commit is contained in:
jrconlin 2019-01-08 14:52:53 -08:00
Родитель 564215d733
Коммит 1b77dab23c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 91B7F708D9FC4D84
8 изменённых файлов: 396 добавлений и 42 удалений

Просмотреть файл

@ -5,9 +5,10 @@ authors = ["Edouard Oger <eoger@fastmail.com>"]
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"

Просмотреть файл

@ -5,4 +5,5 @@ authors = ["Edouard Oger <eoger@fastmail.com>"]
license = "MPL-2.0"
[dependencies]
failure = "0.1.2"
failure = "0.1.5"
base64 = "0.10"

Просмотреть файл

@ -12,7 +12,9 @@ pub trait RemotePublicKey {
}
pub trait LocalKeyPair {
fn generate_random() -> Result<Self> where Self: Sized;
fn generate_random() -> Result<Self>
where
Self: Sized;
/// Export the public key component in the
/// binary uncompressed point representation.
fn pub_as_raw(&self) -> Result<Vec<u8>>;

Просмотреть файл

@ -9,45 +9,16 @@ 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;
//const ECE_AES128GCM_MAX_KEY_ID_LENGTH: usize = 255;
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_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<Vec<u8>>,
}
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<u8>) -> Self {
Self {
rs,
pad_length,
salt: Some(salt),
}
}
}
// TODO: When done, remove the aes128gcm prefixes and the EC_ ones.
// As for now it makes it easier to Ctrl + F into ecec :)

259
src/aesgcm.rs Normal file
Просмотреть файл

@ -0,0 +1,259 @@
/* 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 + 65) * 2
const ECE_WEBPUSH_AESGCM_AUTHINFO: &'static str = "Content-Encoding: auth\0";
// const ECE_WEBPUSH_DEFAULT_RS: u32 = 4096;
// 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<u8>,
pub salt: Vec<u8>,
pub rs: u32,
pub ciphertext: Vec<u8>,
}
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: &str, salt: &str, rs: u32, ciphertext: Vec<u8>) -> Result<AesGcmEncryptedBlock> {
Ok(AesGcmEncryptedBlock {
dh: base64::decode_config(&dh, base64::URL_SAFE_NO_PAD)?,
salt: base64::decode_config(&salt, base64::URL_SAFE_NO_PAD)?,
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<String, String> {
let mut result: HashMap<String, String> = 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<L, R, C> {
_marker1: ::std::marker::PhantomData<L>,
_marker2: ::std::marker::PhantomData<R>,
_marker3: ::std::marker::PhantomData<C>,
}
impl<L, R, C> AesGcmEceWebPush<L, R, C>
where
L: LocalKeyPair,
R: RemotePublicKey,
C: Crypto<LocalKeyPair = L, RemotePublicKey = R>,
{
/// 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<AesGcmEncryptedBlock> {
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<AesGcmEncryptedBlock> {
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: 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<Vec<u8>> {
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<L, R, C> EceWebPush for AesGcmEceWebPush<L, R, C>
where
L: LocalKeyPair,
R: RemotePublicKey,
C: Crypto<LocalKeyPair = L, RemotePublicKey = R>,
{
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<Vec<u8>> {
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<KeyAndNonce> {
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<Vec<u8>> {
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<Vec<u8>> {
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)
}

Просмотреть файл

@ -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<Vec<u8>>,
}
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<u8>) -> Self {
Self {
rs,
pad_length,
salt: Some(salt),
}
}
}
pub enum EceMode {
ENCRYPT,
@ -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() {

Просмотреть файл

@ -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<ece_crypto::Error> for Error {
ErrorKind::from(e).into()
}
}
impl From<base64::DecodeError> for Error {
#[inline]
fn from(_: base64::DecodeError) -> Error {
ErrorKind::DecodeError.into()
}
}

Просмотреть файл

@ -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]
// #[macro_use]
extern crate failure_derive;
mod aes128gcm;
// TODO: mod aesgcm;
mod aesgcm;
mod common;
mod error;
pub use aes128gcm::WebPushParams;
pub use common::WebPushParams;
pub use ece_crypto::{LocalKeyPair, RemotePublicKey};
pub use error::*;
@ -28,6 +29,12 @@ pub type Aes128GcmEceWebPush = aes128gcm::Aes128GcmEceWebPush<
ece_crypto_openssl::OpenSSLCrypto,
>;
pub type AesGcmEceWebPush = aesgcm::AesGcmEceWebPush<
OpenSSLLocalKeyPair,
OpenSSLRemotePublicKey,
ece_crypto_openssl::OpenSSLCrypto,
>;
#[cfg(test)]
mod aes128gcm_tests {
extern crate ece_crypto_openssl;
@ -233,3 +240,78 @@ mod aes128gcm_tests {
};
}
}
// =====================
#[cfg(test)]
mod aesgcm_tests {
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<String> {
// 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(dh, salt, 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.
}