Skip to content

Commit

Permalink
Add support for encrypting S/MIME messages
Browse files Browse the repository at this point in the history
  • Loading branch information
facutuesca committed Apr 25, 2024
1 parent 2018f68 commit ed60635
Show file tree
Hide file tree
Showing 8 changed files with 427 additions and 12 deletions.
5 changes: 5 additions & 0 deletions src/cryptography/hazmat/bindings/_rust/pkcs7.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ def serialize_certificates(
certs: list[x509.Certificate],
encoding: serialization.Encoding,
) -> bytes: ...
def encrypt_and_serialize(
builder: pkcs7.PKCS7EnvelopeBuilder,
encoding: serialization.Encoding,
options: typing.Iterable[pkcs7.PKCS7Options],
) -> bytes: ...
def sign_and_serialize(
builder: pkcs7.PKCS7SignatureBuilder,
encoding: serialization.Encoding,
Expand Down
101 changes: 100 additions & 1 deletion src/cryptography/hazmat/primitives/serialization/pkcs7.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,78 @@ def sign(
return rust_pkcs7.sign_and_serialize(self, encoding, options)


def _smime_encode(
class PKCS7EnvelopeBuilder:
def __init__(
self,
data: bytes | None = None,
recipients: list[x509.Certificate] | None = None,
):
self._data = data
self._recipients = recipients if recipients is not None else []

def set_data(self, data: bytes) -> PKCS7EnvelopeBuilder:
_check_byteslike("data", data)
if self._data is not None:
raise ValueError("data may only be set once")

return PKCS7EnvelopeBuilder(data, self._recipients)

def add_recipient(
self,
certificate: x509.Certificate,
) -> PKCS7EnvelopeBuilder:
if not isinstance(certificate, x509.Certificate):
raise TypeError("certificate must be a x509.Certificate")

if not isinstance(certificate.public_key(), rsa.RSAPublicKey):
raise TypeError("Only RSA keys are supported at this time.")

return PKCS7EnvelopeBuilder(
self._data,
[
*self._recipients,
certificate,
],
)

def encrypt(
self,
encoding: serialization.Encoding,
options: typing.Iterable[PKCS7Options],
backend: typing.Any = None,
) -> bytes:
if len(self._recipients) == 0:
raise ValueError("Must have at least one recipient")
if self._data is None:
raise ValueError("You must add data to encrypt")
options = list(options)
if not all(isinstance(x, PKCS7Options) for x in options):
raise ValueError("options must be from the PKCS7Options enum")
if encoding not in (
serialization.Encoding.PEM,
serialization.Encoding.DER,
serialization.Encoding.SMIME,
):
raise ValueError(
"Must be PEM, DER, or SMIME from the Encoding enum"
)

# These options only make sense when signing, not encrypting
if (
PKCS7Options.NoAttributes in options
or PKCS7Options.NoCapabilities in options
or PKCS7Options.NoCerts in options
or PKCS7Options.DetachedSignature in options
):
raise ValueError(
"The following options are not supported for encryption:"
"NoAttributes, NoCapabilities, NoCerts, DetachedSignature"
)

return rust_pkcs7.encrypt_and_serialize(self, encoding, options)


def _smime_signed_encode(
data: bytes, signature: bytes, micalg: str, text_mode: bool
) -> bytes:
# This function works pretty hard to replicate what OpenSSL does
Expand Down Expand Up @@ -225,6 +296,34 @@ def _smime_encode(
return fp.getvalue()


def _smime_enveloped_encode(data: bytes) -> bytes:
# This function works pretty hard to replicate what OpenSSL does
# precisely. For good and for ill.

m = email.message.Message()
m.add_header("MIME-Version", "1.0")
m.add_header("Content-Disposition", "attachment", filename="smime.p7m")
m.add_header(
"Content-Type",
"application/pkcs7-mime",
smime_type="enveloped-data",
name="smime.p7m",
)
m.add_header("Content-Transfer-Encoding", "base64")

m.set_payload(email.base64mime.body_encode(data, maxlinelen=65))

fp = io.BytesIO()
g = email.generator.BytesGenerator(
fp,
maxheaderlen=0,
mangle_from_=False,
policy=m.policy.clone(linesep="\n"),
)
g.flatten(m)
return fp.getvalue()


class OpenSSLMimePart(email.message.MIMEPart):
# A MIMEPart subclass that replicates OpenSSL's behavior of not including
# a newline if there are no headers.
Expand Down
11 changes: 11 additions & 0 deletions src/rust/cryptography-x509/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ pub enum AlgorithmParameters<'a> {
#[defined_by(oid::RSA_OID)]
Rsa(Option<asn1::Null>),

// Used only in PKCS#7 AlgorithmIdentifiers
// https://datatracker.ietf.org/doc/html/rfc3565#section-4.1
//
// From RFC 3565 section 4.1:
// The AlgorithmIdentifier parameters field MUST be present, and the
// parameters field MUST contain a AES-IV:
//
// AES-IV ::= OCTET STRING (SIZE(16))
#[defined_by(oid::AES_128_CBC_OID)]
AesCbc(&'a [u8]),

// These ECDSA algorithms should have no parameters,
// but Java 11 (up to at least 11.0.19) encodes them
// with NULL parameters. The JDK team is looking to
Expand Down
3 changes: 3 additions & 0 deletions src/rust/cryptography-x509/src/oid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ pub const EC_BRAINPOOLP384R1: asn1::ObjectIdentifier = asn1::oid!(1, 3, 36, 3, 3
pub const EC_BRAINPOOLP512R1: asn1::ObjectIdentifier = asn1::oid!(1, 3, 36, 3, 3, 2, 8, 1, 1, 13);

pub const RSA_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 113549, 1, 1, 1);
pub const AES_256_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 42);
pub const AES_192_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 22);
pub const AES_128_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 2);

// Signing methods
pub const ECDSA_WITH_SHA224_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 10045, 4, 3, 1);
Expand Down
26 changes: 26 additions & 0 deletions src/rust/cryptography-x509/src/pkcs7.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::{certificate, common, csr, name};

pub const PKCS7_DATA_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 113549, 1, 7, 1);
pub const PKCS7_SIGNED_DATA_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 113549, 1, 7, 2);
pub const PKCS7_ENVELOPED_DATA_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 113549, 1, 7, 3);

#[derive(asn1::Asn1Write)]
pub struct ContentInfo<'a> {
Expand All @@ -17,6 +18,8 @@ pub struct ContentInfo<'a> {

#[derive(asn1::Asn1DefinedByWrite)]
pub enum Content<'a> {
#[defined_by(PKCS7_ENVELOPED_DATA_OID)]
EnvelopedData(asn1::Explicit<Box<EnvelopedData<'a>>, 0>),
#[defined_by(PKCS7_SIGNED_DATA_OID)]
SignedData(asn1::Explicit<Box<SignedData<'a>>, 0>),
#[defined_by(PKCS7_DATA_OID)]
Expand Down Expand Up @@ -53,6 +56,29 @@ pub struct SignerInfo<'a> {
pub unauthenticated_attributes: Option<csr::Attributes<'a>>,
}

#[derive(asn1::Asn1Write)]
pub struct EncryptedContentInfo<'a> {
pub _content_type: asn1::ObjectIdentifier,
pub content_encryption_algorithm: common::AlgorithmIdentifier<'a>,
#[implicit(0)]
pub content: Option<&'a [u8]>,
}

#[derive(asn1::Asn1Write)]
pub struct EnvelopedData<'a> {
pub version: u8,
pub recipient_infos: asn1::SetOfWriter<'a, RecipientInfo<'a>>,
pub encrypted_content_info: EncryptedContentInfo<'a>,
}

#[derive(asn1::Asn1Write)]
pub struct RecipientInfo<'a> {
pub version: u8,
pub issuer_and_serial_number: IssuerAndSerialNumber<'a>,
pub key_encryption_algorithm: common::AlgorithmIdentifier<'a>,
pub encrypted_key: &'a [u8],
}

#[derive(asn1::Asn1Write)]
pub struct IssuerAndSerialNumber<'a> {
pub issuer: name::Name<'a>,
Expand Down
119 changes: 111 additions & 8 deletions src/rust/src/pkcs7.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ use std::borrow::Cow;
use std::collections::HashMap;
use std::ops::Deref;

use cryptography_x509::common::{AlgorithmIdentifier, AlgorithmParameters};
use cryptography_x509::csr::Attribute;
use cryptography_x509::pkcs7::PKCS7_DATA_OID;
use cryptography_x509::{common, oid, pkcs7};
use once_cell::sync::Lazy;
#[cfg(not(CRYPTOGRAPHY_IS_BORINGSSL))]
Expand All @@ -27,10 +29,6 @@ const PKCS7_MESSAGE_DIGEST_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 1
const PKCS7_SIGNING_TIME_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 113549, 1, 9, 5);
const PKCS7_SMIME_CAP_OID: asn1::ObjectIdentifier = asn1::oid!(1, 2, 840, 113549, 1, 9, 15);

const AES_256_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 42);
const AES_192_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 22);
const AES_128_CBC_OID: asn1::ObjectIdentifier = asn1::oid!(2, 16, 840, 1, 101, 3, 4, 1, 2);

static OIDS_TO_MIC_NAME: Lazy<HashMap<&asn1::ObjectIdentifier, &str>> = Lazy::new(|| {
let mut h = HashMap::new();
h.insert(&oid::SHA224_OID, "sha-224");
Expand Down Expand Up @@ -79,6 +77,107 @@ fn serialize_certificates<'p>(
encode_der_data(py, "PKCS7".to_string(), content_info_bytes, encoding)
}

#[pyo3::prelude::pyfunction]
fn encrypt_and_serialize<'p>(
py: pyo3::Python<'p>,
builder: &pyo3::Bound<'p, pyo3::PyAny>,
encoding: &pyo3::Bound<'p, pyo3::PyAny>,
options: &pyo3::Bound<'p, pyo3::types::PyList>,
) -> CryptographyResult<pyo3::Bound<'p, pyo3::types::PyBytes>> {
let raw_data: CffiBuf<'p> = builder.getattr(pyo3::intern!(py, "_data"))?.extract()?;
let text_mode = options.contains(types::PKCS7_TEXT.get(py)?)?;
let data_with_header = if options.contains(types::PKCS7_BINARY.get(py)?)? {
Cow::Borrowed(raw_data.as_bytes())
} else {
smime_canonicalize(raw_data.as_bytes(), text_mode).0
};

let padder = types::SYMMETRIC_PADDING_PKCS7
.get(py)?
.call1((128,))?
.call_method0(pyo3::intern!(py, "padder"))?;
let padded_content_start =
padder.call_method1(pyo3::intern!(py, "update"), (data_with_header,))?;
let padded_content_end = padder.call_method0(pyo3::intern!(py, "finalize"))?;
let padded_content = padded_content_start.add(padded_content_end)?;

// The message is encrypted with AES-128-CBC, which the S/MIME v3.2 RFC
// specifies as MUST support (https://datatracker.ietf.org/doc/html/rfc5751#section-2.7)
let key = types::OS_URANDOM.get(py)?.call1((16,))?;
let aes128_algorithm = types::AES128.get(py)?.call1((&key,))?;
let iv = types::OS_URANDOM.get(py)?.call1((16,))?;
let cbc_mode = types::CBC.get(py)?.call1((&iv,))?;
let cipher = types::CIPHER.get(py)?.call1((aes128_algorithm, cbc_mode))?;
let encryptor = cipher.call_method0(pyo3::intern!(py, "encryptor"))?;
let encrypted_content_start =
encryptor.call_method1(pyo3::intern!(py, "update"), (padded_content,))?;
let encrypted_content_end = encryptor.call_method0(pyo3::intern!(py, "finalize"))?;
let encrypted_content = encrypted_content_start.add(encrypted_content_end)?;

let py_recipients: Vec<pyo3::Bound<'p, x509::certificate::Certificate>> = builder
.getattr(pyo3::intern!(py, "_recipients"))?
.extract()?;

let mut recipient_infos = vec![];
let ka_bytes = cryptography_keepalive::KeepAlive::new();
for cert in py_recipients.iter() {
// Currently, keys are encrypted with RSA (PKCS #1 v1.5), which the S/MIME v3.2 RFC
// specifies as MUST support (https://datatracker.ietf.org/doc/html/rfc5751#section-2.3)
let encrypted_key = cert
.call_method0(pyo3::intern!(py, "public_key"))?
.call_method1(
pyo3::intern!(py, "encrypt"),
(&key, types::PKCS1V15.get(py)?.call0()?),
)?
.extract::<pyo3::pybacked::PyBackedBytes>()?;

recipient_infos.push(pkcs7::RecipientInfo {
version: 0,
issuer_and_serial_number: pkcs7::IssuerAndSerialNumber {
issuer: cert.get().raw.borrow_dependent().tbs_cert.issuer.clone(),
serial_number: cert.get().raw.borrow_dependent().tbs_cert.serial,
},
key_encryption_algorithm: AlgorithmIdentifier {
oid: asn1::DefinedByMarker::marker(),
params: AlgorithmParameters::Rsa(Some(())),
},
encrypted_key: ka_bytes.add(encrypted_key),
});
}

let enveloped_data = pkcs7::EnvelopedData {
version: 0,
recipient_infos: asn1::SetOfWriter::new(&recipient_infos),

encrypted_content_info: pkcs7::EncryptedContentInfo {
_content_type: PKCS7_DATA_OID,
content_encryption_algorithm: AlgorithmIdentifier {
oid: asn1::DefinedByMarker::marker(),
params: AlgorithmParameters::AesCbc(iv.extract()?),
},
content: Some(encrypted_content.extract()?),
},
};

let content_info = pkcs7::ContentInfo {
_content_type: asn1::DefinedByMarker::marker(),
content: pkcs7::Content::EnvelopedData(asn1::Explicit::new(Box::new(enveloped_data))),
};
let ci_bytes = asn1::write_single(&content_info)?;

if encoding.is(&types::ENCODING_SMIME.get(py)?) {
Ok(types::SMIME_ENVELOPED_ENCODE
.get(py)?
.call1((&*ci_bytes,))?
.extract()?)
} else {
// Handles the DER, PEM, and error cases
// This uses the "CMS" tag for PEM encoding, matching the behavior of
// `openssl-cms` rather than the legacy `openssl-smime`.
encode_der_data(py, "CMS".to_string(), ci_bytes, encoding)
}
}

#[pyo3::prelude::pyfunction]
fn sign_and_serialize<'p>(
py: pyo3::Python<'p>,
Expand All @@ -105,9 +204,9 @@ fn sign_and_serialize<'p>(
// Subset of values OpenSSL provides:
// https://github.com/openssl/openssl/blob/667a8501f0b6e5705fd611d5bb3ca24848b07154/crypto/pkcs7/pk7_smime.c#L150
// removing all the ones that are bad cryptography
&asn1::SequenceOfWriter::new([AES_256_CBC_OID]),
&asn1::SequenceOfWriter::new([AES_192_CBC_OID]),
&asn1::SequenceOfWriter::new([AES_128_CBC_OID]),
&asn1::SequenceOfWriter::new([oid::AES_256_CBC_OID]),
&asn1::SequenceOfWriter::new([oid::AES_192_CBC_OID]),
&asn1::SequenceOfWriter::new([oid::AES_128_CBC_OID]),
]))?;

#[allow(clippy::type_complexity)]
Expand Down Expand Up @@ -260,7 +359,7 @@ fn sign_and_serialize<'p>(
.map(|d| OIDS_TO_MIC_NAME[&d.oid()])
.collect::<Vec<_>>()
.join(",");
Ok(types::SMIME_ENCODE
Ok(types::SMIME_SIGNED_ENCODE
.get(py)?
.call1((&*data_without_header, &*ci_bytes, mic_algs, text_mode))?
.extract()?)
Expand Down Expand Up @@ -420,6 +519,10 @@ pub(crate) fn create_submodule(
serialize_certificates,
&submod
)?)?;
submod.add_function(pyo3::wrap_pyfunction_bound!(
encrypt_and_serialize,
&submod
)?)?;
submod.add_function(pyo3::wrap_pyfunction_bound!(sign_and_serialize, &submod)?)?;
submod.add_function(pyo3::wrap_pyfunction_bound!(
load_pem_pkcs7_certificates,
Expand Down
14 changes: 12 additions & 2 deletions src/rust/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,9 +324,14 @@ pub static PKCS7_DETACHED_SIGNATURE: LazyPyImport = LazyPyImport::new(
&["PKCS7Options", "DetachedSignature"],
);

pub static SMIME_ENCODE: LazyPyImport = LazyPyImport::new(
pub static SMIME_ENVELOPED_ENCODE: LazyPyImport = LazyPyImport::new(
"cryptography.hazmat.primitives.serialization.pkcs7",
&["_smime_encode"],
&["_smime_enveloped_encode"],
);

pub static SMIME_SIGNED_ENCODE: LazyPyImport = LazyPyImport::new(
"cryptography.hazmat.primitives.serialization.pkcs7",
&["_smime_signed_encode"],
);

pub static PKCS12KEYANDCERTIFICATES: LazyPyImport = LazyPyImport::new(
Expand All @@ -350,6 +355,8 @@ pub static PREHASHED: LazyPyImport = LazyPyImport::new(
"cryptography.hazmat.primitives.asymmetric.utils",
&["Prehashed"],
);
pub static SYMMETRIC_PADDING_PKCS7: LazyPyImport =
LazyPyImport::new("cryptography.hazmat.primitives.padding", &["PKCS7"]);
pub static ASYMMETRIC_PADDING: LazyPyImport = LazyPyImport::new(
"cryptography.hazmat.primitives.asymmetric.padding",
&["AsymmetricPadding"],
Expand Down Expand Up @@ -454,6 +461,9 @@ pub static FFI_CAST: LazyPyImport = LazyPyImport::new(
&["_openssl", "ffi", "cast"],
);

pub static CIPHER: LazyPyImport =
LazyPyImport::new("cryptography.hazmat.primitives.ciphers", &["Cipher"]);

pub static BLOCK_CIPHER_ALGORITHM: LazyPyImport = LazyPyImport::new(
"cryptography.hazmat.primitives.ciphers",
&["BlockCipherAlgorithm"],
Expand Down
Loading

0 comments on commit ed60635

Please sign in to comment.